mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-12 13:44:27 +00:00
Compare commits
182 Commits
remove_del
...
20260325.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d664ab6836 | ||
|
|
a6c4184054 | ||
|
|
cb6985eb7c | ||
|
|
d466ab63bd | ||
|
|
1132cdb364 | ||
|
|
0f9d48a03d | ||
|
|
7e085d9b08 | ||
|
|
1a62c7296c | ||
|
|
be1921229c | ||
|
|
640558ad35 | ||
|
|
99636c9719 | ||
|
|
87758cc228 | ||
|
|
60e8b8b505 | ||
|
|
3c012c30ac | ||
|
|
84d234a330 | ||
|
|
a12543fe74 | ||
|
|
cc53f977a2 | ||
|
|
71541625d7 | ||
|
|
43da700ccc | ||
|
|
efbbdbf3e8 | ||
|
|
eee6f79639 | ||
|
|
9381bbd656 | ||
|
|
2724087290 | ||
|
|
6bdf1ccd8c | ||
|
|
79743c0afa | ||
|
|
15b1df5a58 | ||
|
|
8222d9796c | ||
|
|
3337b414d7 | ||
|
|
ce90d83c92 | ||
|
|
1f6d0d2e63 | ||
|
|
0327b02d0b | ||
|
|
780db9b066 | ||
|
|
89755f274d | ||
|
|
6ea15f507a | ||
|
|
c506fa8990 | ||
|
|
9470863808 | ||
|
|
4bdac1f385 | ||
|
|
5bbfa36228 | ||
|
|
a8070b322c | ||
|
|
9cbc44123e | ||
|
|
c8f4c892f9 | ||
|
|
40b9f9dccb | ||
|
|
823c222a55 | ||
|
|
02acd2996c | ||
|
|
c462fc0639 | ||
|
|
903553dab9 | ||
|
|
25a1c14523 | ||
|
|
f03eee6cb2 | ||
|
|
a2a38e1da7 | ||
|
|
88c063ba2a | ||
|
|
984b50bac7 | ||
|
|
09e4355451 | ||
|
|
7ee76538ae | ||
|
|
eb8b2a9d17 | ||
|
|
10e8c2a148 | ||
|
|
e1a8616ab0 | ||
|
|
ccdd71dd64 | ||
|
|
d3e1d55686 | ||
|
|
4f916abcbf | ||
|
|
4548f9daae | ||
|
|
4020bcec42 | ||
|
|
22c0035e60 | ||
|
|
6b6ad8dd2c | ||
|
|
3bbc3403d6 | ||
|
|
9979bb13ea | ||
|
|
8ac831679d | ||
|
|
db05b07997 | ||
|
|
dba8cefa67 | ||
|
|
6935c55c3c | ||
|
|
635a1185a3 | ||
|
|
585c894c5a | ||
|
|
f9d052a818 | ||
|
|
a29132441d | ||
|
|
479d52bf1d | ||
|
|
d96d78d6f6 | ||
|
|
f80cba341f | ||
|
|
77ee966442 | ||
|
|
2fec5a497e | ||
|
|
ed75d96d3c | ||
|
|
0fac47992b | ||
|
|
91a608c4c5 | ||
|
|
df61953ed4 | ||
|
|
2cda46b4bb | ||
|
|
037190a393 | ||
|
|
ebe0154e32 | ||
|
|
efa73067f6 | ||
|
|
fdb40c9d01 | ||
|
|
5a7ddb4972 | ||
|
|
dbe46d3b3f | ||
|
|
eb43d85439 | ||
|
|
1bbfb79ddb | ||
|
|
cf50db350f | ||
|
|
e04a0ec7dc | ||
|
|
e08576a6dc | ||
|
|
a7831f86ee | ||
|
|
c66e5b379b | ||
|
|
e819c30151 | ||
|
|
e278e33375 | ||
|
|
8313be8e7e | ||
|
|
6d95a59ca0 | ||
|
|
a498ad3d06 | ||
|
|
c4a2229baa | ||
|
|
15245af52d | ||
|
|
c697735e46 | ||
|
|
ddec792ae3 | ||
|
|
cfd0e72609 | ||
|
|
8a5bcd67ab | ||
|
|
a794a80228 | ||
|
|
41ed7d2877 | ||
|
|
b0b86e7ba8 | ||
|
|
e67c4842d4 | ||
|
|
d9c39640e0 | ||
|
|
a8478ab346 | ||
|
|
3ac2434b6f | ||
|
|
f2f1044992 | ||
|
|
53bc66883a | ||
|
|
d795bd1f61 | ||
|
|
869e1d32b3 | ||
|
|
3370bfa9dd | ||
|
|
b1921d1b66 | ||
|
|
c2a2b382e9 | ||
|
|
7d95c2b6cb | ||
|
|
67536a8a64 | ||
|
|
3d89ad4f91 | ||
|
|
36e08367d9 | ||
|
|
9c4aacdb1f | ||
|
|
3feb40a8f4 | ||
|
|
7a310812e0 | ||
|
|
ee77619da3 | ||
|
|
cfa8eb5370 | ||
|
|
d9d2d6aa03 | ||
|
|
1f46f477c7 | ||
|
|
52667b3266 | ||
|
|
c790d2356c | ||
|
|
f24c009dd7 | ||
|
|
8d42395938 | ||
|
|
1a6d46a7ff | ||
|
|
b286b07cfd | ||
|
|
1859d35f7b | ||
|
|
5709af57de | ||
|
|
bb16cc8c00 | ||
|
|
17c6dc52a8 | ||
|
|
1b8211db6d | ||
|
|
2b2bb77a2b | ||
|
|
64749350ef | ||
|
|
043d4eed85 | ||
|
|
2f2e64bb1d | ||
|
|
b74b02c09f | ||
|
|
ab4c3a4316 | ||
|
|
15de137591 | ||
|
|
465c10b945 | ||
|
|
457c51cf58 | ||
|
|
640f2b9245 | ||
|
|
852caa32be | ||
|
|
67ccfa0f6e | ||
|
|
c3cc566fe3 | ||
|
|
38d02a3f30 | ||
|
|
ad1d1e2260 | ||
|
|
b2eb8ec968 | ||
|
|
7b8884f0fd | ||
|
|
aff1fedc9d | ||
|
|
8f5059c24a | ||
|
|
1e72ad1411 | ||
|
|
c9f96bbe69 | ||
|
|
616c3d4657 | ||
|
|
b1ceece224 | ||
|
|
d695c4c845 | ||
|
|
fdbeb12622 | ||
|
|
29ede122a1 | ||
|
|
519d3d0e53 | ||
|
|
030a9a492c | ||
|
|
2685a007e7 | ||
|
|
9ca1cfbf4a | ||
|
|
0793af6846 | ||
|
|
bb7f441d8d | ||
|
|
2813ed7938 | ||
|
|
9ebfa4029b | ||
|
|
6190ba18ea | ||
|
|
81feea1109 | ||
|
|
be430931cc | ||
|
|
e07194027a | ||
|
|
17d9cd192f |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
- name: Setup lint cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
node_modules/.cache/prettier
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -36,14 +36,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
||||
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@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/autobuild@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -57,4 +57,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
||||
|
||||
2
.github/workflows/release-drafter.yaml
vendored
2
.github/workflows/release-drafter.yaml
vendored
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@3a7fb5c85b80b1dda66e1ccb94009adbbd32fce3 # v7.0.0
|
||||
- uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
942
.yarn/releases/yarn-4.12.0.cjs
vendored
942
.yarn/releases/yarn-4.12.0.cjs
vendored
File diff suppressed because one or more lines are too long
940
.yarn/releases/yarn-4.13.0.cjs
vendored
Executable file
940
.yarn/releases/yarn-4.13.0.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
@@ -8,4 +8,4 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
|
||||
@@ -40,18 +40,24 @@ 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 = localeData.match(dataRegex)?.groups?.data;
|
||||
localeData = firstBlock.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
|
||||
localeData = JSON.stringify(JSON.parse(localeData));
|
||||
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
gulp.task("clean-locale-data", async () => deleteSync([outDir]));
|
||||
|
||||
@@ -480,6 +480,12 @@ const SCHEMAS: {
|
||||
},
|
||||
{ type: "string", name: "path", default: "/" },
|
||||
{ type: "boolean", name: "ssl", default: false },
|
||||
{
|
||||
type: "string",
|
||||
name: "comments",
|
||||
default: "disabled field",
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
82
gallery/src/pages/components/ha-input.markdown
Normal file
82
gallery/src/pages/components/ha-input.markdown
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
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
gallery/src/pages/components/ha-input.ts
Normal file
232
gallery/src/pages/components/ha-input.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
51
package.json
51
package.json
@@ -26,7 +26,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.28.6",
|
||||
"@babel/runtime": "7.29.2",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
@@ -37,15 +37,15 @@
|
||||
"@codemirror/view": "6.40.0",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.2.5",
|
||||
"@formatjs/intl-displaynames": "7.2.2",
|
||||
"@formatjs/intl-durationformat": "0.10.1",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.1",
|
||||
"@formatjs/intl-listformat": "8.2.2",
|
||||
"@formatjs/intl-locale": "5.2.1",
|
||||
"@formatjs/intl-numberformat": "9.2.3",
|
||||
"@formatjs/intl-pluralrules": "6.2.3",
|
||||
"@formatjs/intl-relativetimeformat": "12.2.3",
|
||||
"@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",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
@@ -87,14 +87,13 @@
|
||||
"@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",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.48.0",
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
@@ -109,7 +108,7 @@
|
||||
"hls.js": "1.6.15",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.1.2",
|
||||
"intl-messageformat": "11.2.0",
|
||||
"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",
|
||||
@@ -117,7 +116,7 @@
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "17.0.4",
|
||||
"marked": "17.0.5",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -130,9 +129,6 @@
|
||||
"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",
|
||||
@@ -144,17 +140,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.7",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.8",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.0",
|
||||
"@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.2",
|
||||
"@rspack/core": "1.7.8",
|
||||
"@rsdoctor/rspack-plugin": "1.5.5",
|
||||
"@rspack/core": "1.7.9",
|
||||
"@rspack/dev-server": "1.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.25",
|
||||
@@ -172,7 +168,6 @@
|
||||
"@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.0",
|
||||
"babel-loader": "10.1.1",
|
||||
@@ -197,7 +192,7 @@
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "28.1.0",
|
||||
"jsdom": "29.0.1",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
@@ -208,12 +203,12 @@
|
||||
"prettier": "3.8.1",
|
||||
"rspack-manifest-plugin": "5.2.1",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "21.0.2",
|
||||
"tar": "7.5.11",
|
||||
"sinon": "21.0.3",
|
||||
"tar": "7.5.12",
|
||||
"terser-webpack-plugin": "5.4.0",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.0",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -231,8 +226,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.12.0",
|
||||
"packageManager": "yarn@4.13.0",
|
||||
"volta": {
|
||||
"node": "24.14.0"
|
||||
"node": "24.14.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260128.0"
|
||||
version = "20260325.1"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
"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",
|
||||
|
||||
@@ -58,9 +58,11 @@ export class CastManager {
|
||||
this._eventListeners[event].push(listener);
|
||||
|
||||
return () => {
|
||||
this._eventListeners[event].splice(
|
||||
this._eventListeners[event].indexOf(listener)
|
||||
);
|
||||
const listeners = this._eventListeners[event];
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,88 +33,89 @@ export type DateRange =
|
||||
| "now-24h";
|
||||
|
||||
export const calcDateRange = (
|
||||
hass: HomeAssistant,
|
||||
locale: HomeAssistant["locale"],
|
||||
hassConfig: HomeAssistant["config"],
|
||||
range: DateRange
|
||||
): [Date, Date] => {
|
||||
const today = new Date();
|
||||
const weekStartsOn = firstWeekdayIndex(hass.locale);
|
||||
const weekStartsOn = firstWeekdayIndex(locale);
|
||||
switch (range) {
|
||||
case "today":
|
||||
return [
|
||||
calcDate(today, startOfDay, hass.locale, hass.config, {
|
||||
calcDate(today, startOfDay, locale, hassConfig, {
|
||||
weekStartsOn,
|
||||
}),
|
||||
calcDate(today, endOfDay, hass.locale, hass.config, {
|
||||
calcDate(today, endOfDay, locale, hassConfig, {
|
||||
weekStartsOn,
|
||||
}),
|
||||
];
|
||||
case "yesterday":
|
||||
return [
|
||||
calcDate(addDays(today, -1), startOfDay, hass.locale, hass.config, {
|
||||
calcDate(addDays(today, -1), startOfDay, locale, hassConfig, {
|
||||
weekStartsOn,
|
||||
}),
|
||||
calcDate(addDays(today, -1), endOfDay, hass.locale, hass.config, {
|
||||
calcDate(addDays(today, -1), endOfDay, locale, hassConfig, {
|
||||
weekStartsOn,
|
||||
}),
|
||||
];
|
||||
case "this_week":
|
||||
return [
|
||||
calcDate(today, startOfWeek, hass.locale, hass.config, {
|
||||
calcDate(today, startOfWeek, locale, hassConfig, {
|
||||
weekStartsOn,
|
||||
}),
|
||||
calcDate(today, endOfWeek, hass.locale, hass.config, {
|
||||
calcDate(today, endOfWeek, locale, hassConfig, {
|
||||
weekStartsOn,
|
||||
}),
|
||||
];
|
||||
case "this_month":
|
||||
return [
|
||||
calcDate(today, startOfMonth, hass.locale, hass.config),
|
||||
calcDate(today, endOfMonth, hass.locale, hass.config),
|
||||
calcDate(today, startOfMonth, locale, hassConfig),
|
||||
calcDate(today, endOfMonth, locale, hassConfig),
|
||||
];
|
||||
case "this_quarter":
|
||||
return [
|
||||
calcDate(today, startOfQuarter, hass.locale, hass.config),
|
||||
calcDate(today, endOfQuarter, hass.locale, hass.config),
|
||||
calcDate(today, startOfQuarter, locale, hassConfig),
|
||||
calcDate(today, endOfQuarter, locale, hassConfig),
|
||||
];
|
||||
case "this_year":
|
||||
return [
|
||||
calcDate(today, startOfYear, hass.locale, hass.config),
|
||||
calcDate(today, endOfYear, hass.locale, hass.config),
|
||||
calcDate(today, startOfYear, locale, hassConfig),
|
||||
calcDate(today, endOfYear, locale, hassConfig),
|
||||
];
|
||||
case "now-7d":
|
||||
return [
|
||||
calcDate(today, subDays, hass.locale, hass.config, 7),
|
||||
calcDate(today, subDays, hass.locale, hass.config, 0),
|
||||
calcDate(today, subDays, locale, hassConfig, 7),
|
||||
calcDate(today, subDays, locale, hassConfig, 0),
|
||||
];
|
||||
case "now-30d":
|
||||
return [
|
||||
calcDate(today, subDays, hass.locale, hass.config, 30),
|
||||
calcDate(today, subDays, hass.locale, hass.config, 0),
|
||||
calcDate(today, subDays, locale, hassConfig, 30),
|
||||
calcDate(today, subDays, locale, hassConfig, 0),
|
||||
];
|
||||
case "now-12m":
|
||||
return [
|
||||
calcDate(
|
||||
today,
|
||||
(date) => subMonths(startOfMonth(date), 11),
|
||||
hass.locale,
|
||||
hass.config
|
||||
locale,
|
||||
hassConfig
|
||||
),
|
||||
calcDate(today, endOfMonth, hass.locale, hass.config),
|
||||
calcDate(today, endOfMonth, locale, hassConfig),
|
||||
];
|
||||
case "now-1h":
|
||||
return [
|
||||
calcDate(today, subHours, hass.locale, hass.config, 1),
|
||||
calcDate(today, subHours, hass.locale, hass.config, 0),
|
||||
calcDate(today, subHours, locale, hassConfig, 1),
|
||||
calcDate(today, subHours, locale, hassConfig, 0),
|
||||
];
|
||||
case "now-12h":
|
||||
return [
|
||||
calcDate(today, subHours, hass.locale, hass.config, 12),
|
||||
calcDate(today, subHours, hass.locale, hass.config, 0),
|
||||
calcDate(today, subHours, locale, hassConfig, 12),
|
||||
calcDate(today, subHours, locale, hassConfig, 0),
|
||||
];
|
||||
case "now-24h":
|
||||
return [
|
||||
calcDate(today, subHours, hass.locale, hass.config, 24),
|
||||
calcDate(today, subHours, hass.locale, hass.config, 0),
|
||||
calcDate(today, subHours, locale, hassConfig, 24),
|
||||
calcDate(today, subHours, locale, hassConfig, 0),
|
||||
];
|
||||
}
|
||||
return [today, today];
|
||||
|
||||
@@ -261,3 +261,36 @@ 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}`;
|
||||
};
|
||||
|
||||
@@ -29,14 +29,18 @@ export interface EntityNameOptions {
|
||||
|
||||
export const computeEntityNameDisplay = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[] | undefined,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameOptions
|
||||
) => {
|
||||
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
|
||||
let items = ensureArray(name ?? DEFAULT_ENTITY_NAME);
|
||||
|
||||
const separator = options?.separator ?? DEFAULT_SEPARATOR;
|
||||
|
||||
|
||||
@@ -254,6 +254,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"scene",
|
||||
|
||||
@@ -29,6 +29,7 @@ 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"],
|
||||
@@ -270,6 +271,8 @@ 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":
|
||||
|
||||
@@ -6,7 +6,9 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
const compareState = state !== undefined ? state : stateObj?.state;
|
||||
|
||||
if (["button", "event", "input_button", "scene"].includes(domain)) {
|
||||
if (
|
||||
["button", "event", "infrared", "input_button", "scene"].includes(domain)
|
||||
) {
|
||||
return compareState !== UNAVAILABLE;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
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 {
|
||||
@@ -29,7 +10,7 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
}
|
||||
}
|
||||
|
||||
const root = rootEl || getClipboardFallbackRoot();
|
||||
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
|
||||
|
||||
const el = document.createElement("textarea");
|
||||
el.value = str;
|
||||
|
||||
@@ -44,6 +44,7 @@ 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>;
|
||||
}[];
|
||||
};
|
||||
@@ -279,18 +280,23 @@ export class HaChartBase extends LitElement {
|
||||
<div class="chart"></div>
|
||||
</div>
|
||||
${this._renderLegend()}
|
||||
<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 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>
|
||||
</div>
|
||||
`;
|
||||
@@ -333,12 +339,14 @@ 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 =
|
||||
@@ -365,6 +373,7 @@ export class HaChartBase extends LitElement {
|
||||
})}
|
||||
></div>
|
||||
<div class="label">${name}</div>
|
||||
${value ? html`<div class="value">${value}</div>` : nothing}
|
||||
</li>`;
|
||||
})}
|
||||
${items.length > overflowLimit
|
||||
@@ -578,7 +587,10 @@ export class HaChartBase extends LitElement {
|
||||
id: "dataZoom",
|
||||
type: "inside",
|
||||
orient: "horizontal",
|
||||
filterMode: "none",
|
||||
// "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.
|
||||
filterMode: "boundaryFilter" as any,
|
||||
xAxisIndex: 0,
|
||||
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||
@@ -1109,16 +1121,35 @@ export class HaChartBase extends LitElement {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.chart-controls {
|
||||
.top-controls {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 4px;
|
||||
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 {
|
||||
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,
|
||||
@@ -1166,6 +1197,9 @@ 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);
|
||||
}
|
||||
@@ -1174,6 +1208,12 @@ 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;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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,
|
||||
@@ -76,8 +78,20 @@ 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 = true;
|
||||
@@ -117,6 +131,9 @@ 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(
|
||||
@@ -124,12 +141,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
this._physicsEnabled,
|
||||
this._reducedMotion,
|
||||
this._showLabels,
|
||||
isMobile
|
||||
isMobile,
|
||||
hasHighlightedNodes
|
||||
)}
|
||||
.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"
|
||||
@@ -165,7 +184,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
...category,
|
||||
icon: category.symbol,
|
||||
})),
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
},
|
||||
dataZoom: {
|
||||
type: "inside",
|
||||
@@ -175,13 +194,56 @@ 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
|
||||
isMobile: boolean,
|
||||
hasHighlightedNodes?: boolean
|
||||
) => ({
|
||||
id: "network",
|
||||
type: "graph",
|
||||
@@ -214,7 +276,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: isMobile ? "none" : "adjacency",
|
||||
focus: hasHighlightedNodes ? "self" : isMobile ? "none" : "adjacency",
|
||||
},
|
||||
force: {
|
||||
repulsion: [400, 600],
|
||||
@@ -362,6 +424,68 @@ 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;
|
||||
|
||||
@@ -239,7 +239,9 @@ export class StateHistoryChartLine extends LitElement {
|
||||
changedProps.has("fitYData") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_visualMap") ||
|
||||
changedProps.has("_yWidth")
|
||||
changedProps.has("_yWidth") ||
|
||||
(changedProps.has("hass") &&
|
||||
this._hasEntityStatesChanged(changedProps.get("hass")))
|
||||
) {
|
||||
const rtl = computeRTL(this.hass);
|
||||
let minYAxis: number | ((values: { min: number }) => number) | undefined =
|
||||
@@ -296,6 +298,19 @@ 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,
|
||||
@@ -316,6 +331,13 @@ 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);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
statisticsHaveType,
|
||||
} from "../../data/recorder";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
@@ -398,7 +399,31 @@ export class StatisticsChart extends LitElement {
|
||||
endTime = new Date();
|
||||
}
|
||||
|
||||
let unit: string | undefined | null;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
const names = this.names || {};
|
||||
statisticsData.forEach(([statistic_id, stats]) => {
|
||||
@@ -408,18 +433,6 @@ 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;
|
||||
@@ -543,7 +556,7 @@ export class StatisticsChart extends LitElement {
|
||||
(series as LineSeriesOption).areaStyle = undefined;
|
||||
} else {
|
||||
series.stackOrder = "seriesAsc";
|
||||
if (drawBands && type === bandTop) {
|
||||
if (type === bandTop) {
|
||||
(series as LineSeriesOption).areaStyle = {
|
||||
color: color + "3F",
|
||||
};
|
||||
@@ -623,13 +636,14 @@ export class StatisticsChart extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// 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(":")) {
|
||||
// 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)) {
|
||||
const stateObj = this.hass.states[statistic_id];
|
||||
if (stateObj) {
|
||||
const currentValue = parseFloat(stateObj.state);
|
||||
@@ -670,10 +684,6 @@ 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({
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-svg-icon";
|
||||
import "../search-input";
|
||||
import "../input/ha-input-search";
|
||||
import { filterData, sortData } from "./sort-filter";
|
||||
|
||||
export interface RowClickedEvent {
|
||||
@@ -391,11 +391,11 @@ export class HaDataTable extends LitElement {
|
||||
${this._filterable
|
||||
? html`
|
||||
<div class="table-header">
|
||||
<search-input
|
||||
.hass=${this.hass}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
.label=${this.searchLabel}
|
||||
></search-input>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
@input=${this._handleSearchChange}
|
||||
.placeholder=${this.searchLabel}
|
||||
></ha-input-search>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
@@ -970,12 +970,12 @@ export class HaDataTable extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent): void {
|
||||
private _handleSearchChange(ev: InputEvent): void {
|
||||
if (this.filter) {
|
||||
return;
|
||||
}
|
||||
this._lastSelectedRowId = null;
|
||||
this._debounceSearch(ev.detail.value);
|
||||
this._debounceSearch((ev.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
private async _calcTableHeight() {
|
||||
@@ -1388,11 +1388,9 @@ export class HaDataTable extends LitElement {
|
||||
.table-header {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
search-input {
|
||||
display: block;
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
--mdc-text-field-fill-color: var(--sidebar-background-color);
|
||||
--mdc-text-field-idle-line-color: transparent;
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
slot[name="header"] {
|
||||
display: block;
|
||||
|
||||
348
src/components/date-picker/date-range-picker.ts
Normal file
348
src/components/date-picker/date-range-picker.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
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, 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 type { ValueChangedEvent } from "../../types";
|
||||
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 { DateRangePickerRanges } from "./ha-date-range-picker";
|
||||
import { datePickerStyles, dateRangePickerStyles } from "./styles";
|
||||
|
||||
@customElement("date-range-picker")
|
||||
export class DateRangePicker extends 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 },
|
||||
};
|
||||
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="picker">
|
||||
${this.ranges !== false && this.ranges
|
||||
? html`<div class="date-range-ranges">
|
||||
<ha-list @action=${this._setDateRange} activatable>
|
||||
${Object.keys(this.ranges).map(
|
||||
(name) => html`<ha-list-item>${name}</ha-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
</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
|
||||
></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
|
||||
></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) {
|
||||
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 _setDateRange(ev: CustomEvent<ActionDetail>) {
|
||||
const dateRange: [Date, Date] = Object.values(this.ranges!)[
|
||||
ev.detail.index
|
||||
];
|
||||
this._dateValue = formatCallyDateRange(
|
||||
dateRange[0],
|
||||
dateRange[1],
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
);
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
},
|
||||
});
|
||||
fireEvent(this, "preset-selected", {
|
||||
index: ev.detail.index,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleChangeTime(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const time = ev.detail.value;
|
||||
const type = (ev.target as HaBaseTimeInput).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,
|
||||
css`
|
||||
.picker {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.date-range-ranges {
|
||||
border-right: 1px solid var(--divider-color);
|
||||
}
|
||||
.range {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.date-range-ranges {
|
||||
max-width: 30%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"date-range-picker": DateRangePicker;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"cancel-date-picker": undefined;
|
||||
"preset-selected": { index: number };
|
||||
}
|
||||
}
|
||||
406
src/components/date-picker/ha-date-range-picker.ts
Normal file
406
src/components/date-picker/ha-date-range-picker.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,22 @@ import {
|
||||
formatDateMonth,
|
||||
formatDateShort,
|
||||
formatDateYear,
|
||||
} 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";
|
||||
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"];
|
||||
|
||||
@@ -75,7 +81,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
? {
|
||||
year: this._pickerYear,
|
||||
title: formatDateShort(date, this.locale, this.hassConfig),
|
||||
dateString: this.params.value.substring(0, 10),
|
||||
dateString: formatISODateOnly(date, this.locale, this.hassConfig),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
@@ -160,7 +166,8 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
this._value = {
|
||||
year: formatDateYear(date, this.locale, this.hassConfig),
|
||||
title: formatDateShort(date, this.locale, this.hassConfig),
|
||||
dateString: value || date.toISOString().substring(0, 10),
|
||||
dateString:
|
||||
value || formatISODateOnly(date, this.locale, this.hassConfig),
|
||||
};
|
||||
|
||||
if (setFocusDay) {
|
||||
@@ -196,81 +203,14 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
calendar-date {
|
||||
width: 100%;
|
||||
}
|
||||
calendar-date::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: 100%;
|
||||
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),
|
||||
calendar-month::part(selected):focus-visible {
|
||||
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: 1px solid var(--ha-color-neutral-60);
|
||||
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(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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
datePickerStyles,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
144
src/components/date-picker/styles.ts
Normal file
144
src/components/date-picker/styles.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
calendar-month {
|
||||
min-height: calc(34px * 7);
|
||||
}
|
||||
calendar-month::part(day) {
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
calendar-month::part(button) {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
calendar-month::part(range-inner),
|
||||
calendar-month::part(range-start),
|
||||
calendar-month::part(range-end),
|
||||
calendar-month::part(selected),
|
||||
calendar-month::part(selected):hover {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
.heading {
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.month-year {
|
||||
margin-left: 40px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
`;
|
||||
@@ -1,359 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
DEFAULT_ENTITY_NAME,
|
||||
type EntityNameItem,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
@@ -14,12 +18,14 @@ 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>
|
||||
@@ -70,10 +76,291 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
@state() private _mode?: "composed" | "custom";
|
||||
|
||||
@query("ha-generic-picker") 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) : [...DEFAULT_ENTITY_NAME];
|
||||
});
|
||||
|
||||
private _toValue = memoizeOne(
|
||||
(items: EntityNameItem[]): typeof this.value => {
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
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) {
|
||||
@@ -158,157 +445,6 @@ 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];
|
||||
@@ -359,58 +495,6 @@ 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;
|
||||
@@ -418,13 +502,42 @@ 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);
|
||||
}
|
||||
.container:after {
|
||||
.field:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -442,30 +555,25 @@ export class HaEntityNamePicker extends LitElement {
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
:host([disabled]) .container:after {
|
||||
:host([disabled]) .field:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
.container:focus-within:after {
|
||||
.field:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2) var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -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 "./ha-multi-textfield";
|
||||
import "./input/ha-input-multi";
|
||||
|
||||
@customElement("ha-aliases-editor")
|
||||
class AliasesEditor extends LitElement {
|
||||
@@ -12,28 +12,32 @@ 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-multi-textfield
|
||||
.hass=${this.hass}
|
||||
<ha-input-multi
|
||||
.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-multi-textfield>
|
||||
</ha-input-multi>
|
||||
`;
|
||||
}
|
||||
|
||||
private _aliasesChanged(value) {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
private _aliasesChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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,7 +15,6 @@ 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?: {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { mdiAlertCircle, mdiMicrophone, mdiSend } from "@mdi/js";
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiChevronDown,
|
||||
mdiChevronUp,
|
||||
mdiCommentProcessingOutline,
|
||||
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,
|
||||
@@ -14,17 +20,28 @@ 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 "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
import "./input/ha-input";
|
||||
import type { HaInput } from "./input/ha-input";
|
||||
|
||||
interface AssistMessage {
|
||||
who: string;
|
||||
text?: string | TemplateResult;
|
||||
text: string | TemplateResult;
|
||||
thinking: string;
|
||||
thinking_expanded?: boolean;
|
||||
tool_calls: Record<
|
||||
string,
|
||||
{
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
result?: any;
|
||||
}
|
||||
>;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
@@ -40,7 +57,7 @@ export class HaAssistChat extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public startListening?: boolean;
|
||||
|
||||
@query("#message-input") private _messageInput!: HaTextField;
|
||||
@query("#message-input") private _messageInput!: HaInput;
|
||||
|
||||
@query(".message:last-child")
|
||||
private _lastChatMessage!: LitElement;
|
||||
@@ -70,6 +87,8 @@ export class HaAssistChat extends LitElement {
|
||||
{
|
||||
who: "hass",
|
||||
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -127,29 +146,114 @@ export class HaAssistChat extends LitElement {
|
||||
`}
|
||||
<div class="spacer"></div>
|
||||
${this._conversation!.map(
|
||||
(message) => html`
|
||||
<ha-markdown
|
||||
class="message ${classMap({
|
||||
error: !!message.error,
|
||||
[message.who]: true,
|
||||
})}"
|
||||
breaks
|
||||
cache
|
||||
.content=${message.text}
|
||||
>
|
||||
</ha-markdown>
|
||||
(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>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div class="input" slot="primaryAction">
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
id="message-input"
|
||||
@keyup=${this._handleKeyUp}
|
||||
@input=${this._handleInput}
|
||||
.label=${this.hass.localize(`ui.dialogs.voice_command.input_label`)}
|
||||
.iconTrailing=${true}
|
||||
>
|
||||
<div slot="trailingIcon">
|
||||
<div slot="end">
|
||||
${this._showSendButton || !supportsSTT
|
||||
? html`
|
||||
<ha-icon-button
|
||||
@@ -194,7 +298,7 @@ export class HaAssistChat extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</ha-textfield>
|
||||
</ha-input>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -224,7 +328,7 @@ export class HaAssistChat extends LitElement {
|
||||
}
|
||||
|
||||
private _handleKeyUp(ev: KeyboardEvent) {
|
||||
const input = ev.target as HaTextField;
|
||||
const input = ev.target as HaInput;
|
||||
if (!this._processing && ev.key === "Enter" && input.value) {
|
||||
this._processText(input.value);
|
||||
input.value = "";
|
||||
@@ -233,7 +337,7 @@ export class HaAssistChat extends LitElement {
|
||||
}
|
||||
|
||||
private _handleInput(ev: InputEvent) {
|
||||
const value = (ev.target as HaTextField).value;
|
||||
const value = (ev.target as HaInput).value;
|
||||
if (value && !this._showSendButton) {
|
||||
this._showSendButton = true;
|
||||
} else if (!value && this._showSendButton) {
|
||||
@@ -268,6 +372,15 @@ export class HaAssistChat extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -296,7 +409,9 @@ export class HaAssistChat extends LitElement {
|
||||
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
|
||||
)}</a>`,
|
||||
}
|
||||
)}`,
|
||||
)}`,
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -317,6 +432,8 @@ export class HaAssistChat extends LitElement {
|
||||
const userMessage: AssistMessage = {
|
||||
who: "user",
|
||||
text: "…",
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
};
|
||||
await this._audioRecorder.start();
|
||||
|
||||
@@ -448,7 +565,7 @@ export class HaAssistChat extends LitElement {
|
||||
private async _processText(text: string) {
|
||||
this._unloadAudio();
|
||||
this._processing = true;
|
||||
this._addMessage({ who: "user", text });
|
||||
this._addMessage({ who: "user", text, thinking: "", tool_calls: {} });
|
||||
const hassMessageProcesser = this._createAddHassMessageProcessor();
|
||||
hassMessageProcesser.addMessage();
|
||||
try {
|
||||
@@ -487,17 +604,23 @@ export class HaAssistChat extends LitElement {
|
||||
let currentDeltaRole = "";
|
||||
|
||||
const progressToNextMessage = () => {
|
||||
if (progress.hassMessage.text === "…") {
|
||||
if (
|
||||
progress.hassMessage.text === "…" &&
|
||||
!progress.hassMessage.thinking &&
|
||||
(!progress.hassMessage.tool_calls ||
|
||||
Object.keys(progress.hassMessage.tool_calls).length === 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
progress.hassMessage.text = progress.hassMessage.text.substring(
|
||||
0,
|
||||
progress.hassMessage.text.length - 1
|
||||
);
|
||||
if (progress.hassMessage.text?.endsWith("…")) {
|
||||
progress.hassMessage.text = progress.hassMessage.text.slice(0, -1);
|
||||
}
|
||||
|
||||
progress.hassMessage = {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
error: false,
|
||||
};
|
||||
this._addMessage(progress.hassMessage);
|
||||
@@ -513,16 +636,13 @@ export class HaAssistChat extends LitElement {
|
||||
): _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: () => {
|
||||
@@ -540,29 +660,37 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
// new message
|
||||
if (delta.role) {
|
||||
progressToNextMessage();
|
||||
currentDeltaRole = delta.role;
|
||||
}
|
||||
|
||||
if (isAssistantDelta(delta)) {
|
||||
if (delta.content) {
|
||||
progress.hassMessage.text =
|
||||
progress.hassMessage.text.substring(
|
||||
0,
|
||||
progress.hassMessage.text.length - 1
|
||||
) +
|
||||
delta.content +
|
||||
"…";
|
||||
this.requestUpdate("_conversation");
|
||||
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;
|
||||
}
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
tools[toolCall.id] = toolCall;
|
||||
progress.hassMessage.tool_calls[toolCall.id] = toolCall;
|
||||
}
|
||||
}
|
||||
this.requestUpdate("_conversation");
|
||||
} else if (isToolResult(delta)) {
|
||||
if (tools[delta.tool_call_id]) {
|
||||
delete tools[delta.tool_call_id];
|
||||
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
|
||||
progress.hassMessage.tool_calls[delta.tool_call_id].result =
|
||||
delta.tool_result;
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
} else if (event.type === "intent-end") {
|
||||
@@ -599,9 +727,10 @@ export class HaAssistChat extends LitElement {
|
||||
ha-alert {
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
#message-input::part(wa-base) {
|
||||
padding-right: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1 1 400px;
|
||||
display: block;
|
||||
@@ -619,6 +748,17 @@ export class HaAssistChat extends LitElement {
|
||||
.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;
|
||||
@@ -666,6 +806,89 @@ export class HaAssistChat extends LitElement {
|
||||
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);
|
||||
@@ -721,20 +944,6 @@ export class HaAssistChat extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -133,6 +134,9 @@ 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?: HaInput[];
|
||||
|
||||
static shadowRootOptions = {
|
||||
@@ -158,7 +162,8 @@ export class HaBaseTimeInput extends LitElement {
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
.value=${this.days.toFixed()}
|
||||
.label=${this.dayLabel}
|
||||
.label=${!this.placeholderLabels ? this.dayLabel : ""}
|
||||
.placeholder=${this.placeholderLabels ? this.dayLabel : ""}
|
||||
name="days"
|
||||
@change=${this._valueChanged}
|
||||
@focusin=${this._onFocus}
|
||||
@@ -178,7 +183,8 @@ export class HaBaseTimeInput extends LitElement {
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
.value=${this.hours.toFixed()}
|
||||
.label=${this.hourLabel}
|
||||
.label=${!this.placeholderLabels ? this.hourLabel : ""}
|
||||
.placeholder=${this.placeholderLabels ? this.hourLabel : ""}
|
||||
name="hours"
|
||||
@change=${this._valueChanged}
|
||||
@focusin=${this._onFocus}
|
||||
@@ -197,7 +203,8 @@ export class HaBaseTimeInput extends LitElement {
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
.value=${this._formatValue(this.minutes)}
|
||||
.label=${this.minLabel}
|
||||
.label=${!this.placeholderLabels ? this.minLabel : ""}
|
||||
.placeholder=${this.placeholderLabels ? this.minLabel : ""}
|
||||
@change=${this._valueChanged}
|
||||
@focusin=${this._onFocus}
|
||||
name="minutes"
|
||||
@@ -220,7 +227,8 @@ export class HaBaseTimeInput extends LitElement {
|
||||
inputmode="decimal"
|
||||
step="any"
|
||||
.value=${this._formatValue(this.seconds)}
|
||||
.label=${this.secLabel}
|
||||
.label=${!this.placeholderLabels ? this.secLabel : ""}
|
||||
.placeholder=${this.placeholderLabels ? this.secLabel : ""}
|
||||
@change=${this._valueChanged}
|
||||
@focusin=${this._onFocus}
|
||||
name="seconds"
|
||||
@@ -241,7 +249,8 @@ export class HaBaseTimeInput extends LitElement {
|
||||
id="millisec"
|
||||
type="number"
|
||||
.value=${this._formatValue(this.milliseconds, 3)}
|
||||
.label=${this.millisecLabel}
|
||||
.label=${!this.placeholderLabels ? this.millisecLabel : ""}
|
||||
.placeholder=${this.placeholderLabels ? this.millisecLabel : ""}
|
||||
@change=${this._valueChanged}
|
||||
@focusin=${this._onFocus}
|
||||
name="milliseconds"
|
||||
@@ -263,6 +272,8 @@ export class HaBaseTimeInput extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.name=${"amPm"}
|
||||
@selected=${this._valueChanged}
|
||||
@wa-after-hide=${stopPropagation}
|
||||
@wa-hide=${stopPropagation}
|
||||
.options=${["AM", "PM"]}
|
||||
>
|
||||
</ha-select>`}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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;
|
||||
|
||||
@@ -90,21 +89,22 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
await this.updateComplete;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
// }
|
||||
(
|
||||
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
|
||||
)?.focus();
|
||||
|
||||
@@ -57,6 +57,7 @@ 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;
|
||||
|
||||
@@ -8,10 +8,14 @@ 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 {
|
||||
@@ -30,8 +34,24 @@ 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()
|
||||
@@ -71,6 +91,7 @@ export class HaColorPicker extends LitElement {
|
||||
const colors = this._getColors(
|
||||
this.includeNone,
|
||||
this.includeState,
|
||||
this.extraOptions,
|
||||
this.defaultColor,
|
||||
this.value
|
||||
);
|
||||
@@ -93,6 +114,7 @@ export class HaColorPicker extends LitElement {
|
||||
this._getColors(
|
||||
this.includeNone,
|
||||
this.includeState,
|
||||
this.extraOptions,
|
||||
this.defaultColor,
|
||||
this.value
|
||||
);
|
||||
@@ -101,6 +123,7 @@ export class HaColorPicker extends LitElement {
|
||||
(
|
||||
includeNone: boolean,
|
||||
includeState: boolean,
|
||||
extraOptions: UiColorExtraOption[] | undefined,
|
||||
defaultColor: string | undefined,
|
||||
currentValue: string | undefined
|
||||
): PickerComboBoxItem[] => {
|
||||
@@ -132,6 +155,19 @@ 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?.(
|
||||
@@ -143,14 +179,11 @@ export class HaColorPicker extends LitElement {
|
||||
});
|
||||
});
|
||||
|
||||
const isSpecial =
|
||||
currentValue === "none" ||
|
||||
currentValue === "state" ||
|
||||
THEME_COLORS.has(currentValue || "");
|
||||
const knownIds = new Set(items.map((item) => item.id));
|
||||
|
||||
const hasValue = currentValue && currentValue.length > 0;
|
||||
|
||||
if (hasValue && !isSpecial) {
|
||||
if (hasValue && !knownIds.has(currentValue!)) {
|
||||
items.push({
|
||||
id: currentValue!,
|
||||
primary: currentValue!,
|
||||
@@ -161,21 +194,27 @@ 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>
|
||||
${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>`}
|
||||
${this._renderItemIcon(item)}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
@@ -201,13 +240,23 @@ 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`
|
||||
<span slot="start">${this._renderColorCircle(value)}</span>
|
||||
<span slot="headline">
|
||||
${this.localize?.(
|
||||
`ui.components.color-picker.colors.${value}` as LocalizeKeys
|
||||
) || value}
|
||||
</span>
|
||||
${startSlot}
|
||||
<span slot="headline">${label}</span>
|
||||
`;
|
||||
};
|
||||
|
||||
|
||||
@@ -43,30 +43,6 @@ 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;
|
||||
}
|
||||
@@ -170,6 +146,39 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import { TimeZone } from "../data/translation";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
import "./input/ha-input";
|
||||
import type { HaInput } from "./input/ha-input";
|
||||
|
||||
const loadDatePickerDialog = () => import("./ha-dialog-date-picker");
|
||||
const loadDatePickerDialog = () =>
|
||||
import("./date-picker/ha-dialog-date-picker");
|
||||
|
||||
export interface DatePickerDialogParams {
|
||||
value?: string;
|
||||
@@ -53,19 +54,17 @@ export class HaDateInput extends LitElement {
|
||||
|
||||
@property({ attribute: "can-clear", type: Boolean }) public canClear = false;
|
||||
|
||||
@query("ha-textfield", true) private _input?: HaTextField;
|
||||
@query("ha-input", true) private _input?: HaInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<ha-textfield
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
return html`<ha-input
|
||||
.label=${this.label ?? ""}
|
||||
.hint=${this.helper ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
iconTrailing
|
||||
helperPersistent
|
||||
readonly
|
||||
@click=${this._openDialog}
|
||||
@keydown=${this._keyDown}
|
||||
@@ -81,8 +80,8 @@ export class HaDateInput extends LitElement {
|
||||
: ""}
|
||||
.required=${this.required}
|
||||
>
|
||||
<ha-svg-icon slot="trailingIcon" .path=${mdiCalendar}></ha-svg-icon>
|
||||
</ha-textfield>`;
|
||||
<ha-svg-icon slot="end" .path=${mdiCalendar}></ha-svg-icon>
|
||||
</ha-input>`;
|
||||
}
|
||||
|
||||
private _openDialog() {
|
||||
@@ -127,9 +126,6 @@ export class HaDateInput extends LitElement {
|
||||
ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
declare global {
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,9 @@ 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 { authContext, localizeContext } from "../data/context";
|
||||
import { 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";
|
||||
|
||||
@@ -127,9 +126,10 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
private localize!: ContextType<typeof localizeContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: authContext, subscribe: true })
|
||||
private auth?: ContextType<typeof authContext>;
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// @state()
|
||||
// @consume({ context: authContext, subscribe: true })
|
||||
// private auth?: ContextType<typeof authContext>;
|
||||
|
||||
@state()
|
||||
private _bodyScrolled = false;
|
||||
@@ -221,21 +221,22 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
await this.updateComplete;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
// }
|
||||
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,7 +15,8 @@ import type { HomeAssistant } from "../types";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-list";
|
||||
import "./search-input-outlined";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
@customElement("ha-filter-devices")
|
||||
export class HaFilterDevices extends LitElement {
|
||||
@@ -67,12 +68,12 @@ export class HaFilterDevices extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
${this._shouldRender
|
||||
? html`<search-input-outlined
|
||||
.hass=${this.hass}
|
||||
.filter=${this._filter}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</search-input-outlined>
|
||||
</ha-input-search>
|
||||
<ha-list class="ha-scrollbar" multi>
|
||||
<lit-virtualizer
|
||||
.items=${this._devices(
|
||||
@@ -138,8 +139,9 @@ export class HaFilterDevices extends LitElement {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value.toLowerCase();
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
private _devices = memoizeOne(
|
||||
@@ -249,7 +251,7 @@ export class HaFilterDevices extends LitElement {
|
||||
ha-check-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
search-input-outlined {
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import "./ha-check-list-item";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-list";
|
||||
import "./search-input-outlined";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
@customElement("ha-filter-domains")
|
||||
export class HaFilterDomains extends LitElement {
|
||||
@@ -49,12 +50,12 @@ export class HaFilterDomains extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
${this._shouldRender
|
||||
? html`<search-input-outlined
|
||||
.hass=${this.hass}
|
||||
.filter=${this._filter}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</search-input-outlined>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@click=${this._handleItemClick}
|
||||
@@ -155,8 +156,9 @@ export class HaFilterDomains extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value.toLowerCase();
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -201,7 +203,7 @@ export class HaFilterDomains extends LitElement {
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
search-input-outlined {
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import "./ha-check-list-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-list";
|
||||
import "./ha-state-icon";
|
||||
import "./search-input-outlined";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
@customElement("ha-filter-entities")
|
||||
export class HaFilterEntities extends LitElement {
|
||||
@@ -70,12 +71,12 @@ export class HaFilterEntities extends LitElement {
|
||||
</div>
|
||||
${this._shouldRender
|
||||
? html`
|
||||
<search-input-outlined
|
||||
.hass=${this.hass}
|
||||
.filter=${this._filter}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</search-input-outlined>
|
||||
</ha-input-search>
|
||||
<ha-list class="ha-scrollbar" multi>
|
||||
<lit-virtualizer
|
||||
.items=${this._entities(
|
||||
@@ -149,8 +150,9 @@ export class HaFilterEntities extends LitElement {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value.toLowerCase();
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
private _entities = memoizeOne(
|
||||
@@ -265,7 +267,7 @@ export class HaFilterEntities extends LitElement {
|
||||
--mdc-list-item-graphic-margin: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
search-input-outlined {
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import "./ha-check-list-item";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-list";
|
||||
import "./search-input-outlined";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
@customElement("ha-filter-integrations")
|
||||
export class HaFilterIntegrations extends LitElement {
|
||||
@@ -52,12 +53,12 @@ export class HaFilterIntegrations extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
${this._manifests && this._shouldRender
|
||||
? html`<search-input-outlined
|
||||
.hass=${this.hass}
|
||||
.filter=${this._filter}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</search-input-outlined>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@click=${this._handleItemClick}
|
||||
@@ -175,8 +176,9 @@ export class HaFilterIntegrations extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value.toLowerCase();
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -221,7 +223,7 @@ export class HaFilterIntegrations extends LitElement {
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
search-input-outlined {
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import { computeCssColor } from "../common/color/compute-color";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import { labelsContext } from "../data/context";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-check-list-item";
|
||||
@@ -21,7 +21,8 @@ import "./ha-icon-button";
|
||||
import "./ha-label";
|
||||
import "./ha-list";
|
||||
import "./ha-list-item";
|
||||
import "./search-input-outlined";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
@customElement("ha-filter-labels")
|
||||
export class HaFilterLabels extends LitElement {
|
||||
@@ -79,12 +80,12 @@ export class HaFilterLabels extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
${this._shouldRender
|
||||
? html`<search-input-outlined
|
||||
.hass=${this.hass}
|
||||
.filter=${this._filter}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</search-input-outlined>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
@selected=${this._labelSelected}
|
||||
class="ha-scrollbar"
|
||||
@@ -163,8 +164,9 @@ export class HaFilterLabels extends LitElement {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value.toLowerCase();
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const value = (ev.target as HaInputSearch).value ?? "";
|
||||
this._filter = value.toLowerCase();
|
||||
}
|
||||
|
||||
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
|
||||
@@ -261,7 +263,7 @@ export class HaFilterLabels extends LitElement {
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
search-input-outlined {
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import "../ha-textfield";
|
||||
import type { HaTextField } from "../ha-textfield";
|
||||
import "../input/ha-input";
|
||||
import type { HaInput } from "../input/ha-input";
|
||||
import type {
|
||||
HaFormElement,
|
||||
HaFormFloatData,
|
||||
@@ -25,7 +25,7 @@ export class HaFormFloat extends LitElement implements HaFormElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-textfield", true) private _input?: HaTextField;
|
||||
@query("ha-input", true) private _input?: HaInput;
|
||||
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
@@ -38,23 +38,25 @@ export class HaFormFloat extends LitElement implements HaFormElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
helperPersistent
|
||||
.hint=${this.helper}
|
||||
.value=${this.data !== undefined ? this.data : ""}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.schema.required}
|
||||
.autoValidate=${this.schema.required}
|
||||
.suffix=${this.schema.description?.suffix}
|
||||
.validationMessage=${this.schema.required
|
||||
? this.localize?.("ui.common.error_required")
|
||||
: undefined}
|
||||
@input=${this._valueChanged}
|
||||
></ha-textfield>
|
||||
@input=${this._handleInput}
|
||||
>
|
||||
${this.schema.description?.suffix
|
||||
? html`<span slot="end">${this.schema.description?.suffix}</span>`
|
||||
: nothing}
|
||||
</ha-input>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -64,9 +66,9 @@ export class HaFormFloat extends LitElement implements HaFormElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _valueChanged(ev: Event) {
|
||||
const source = ev.target as HaTextField;
|
||||
const rawValue = source.value.replace(",", ".");
|
||||
private _handleInput(ev: InputEvent) {
|
||||
const source = ev.target as HaInput;
|
||||
const rawValue = (source.value ?? "").replace(",", ".");
|
||||
|
||||
let value: number | undefined;
|
||||
|
||||
@@ -74,6 +76,11 @@ export class HaFormFloat extends LitElement implements HaFormElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow user to keep typing decimal places (e.g., 5.0, 5.00, 5.10)
|
||||
if (rawValue.includes(".") && rawValue.endsWith("0")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow user to start typing a negative value
|
||||
if (rawValue === "-") {
|
||||
return;
|
||||
@@ -105,9 +112,6 @@ export class HaFormFloat extends LitElement implements HaFormElement {
|
||||
:host([own-margin]) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
@@ -7,8 +7,8 @@ import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-slider";
|
||||
import "../ha-textfield";
|
||||
import type { HaTextField } from "../ha-textfield";
|
||||
import "../input/ha-input";
|
||||
import type { HaInput } from "../input/ha-input";
|
||||
import type {
|
||||
HaFormElement,
|
||||
HaFormIntegerData,
|
||||
@@ -29,8 +29,8 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-textfield, ha-slider", true) private _input?:
|
||||
| HaTextField
|
||||
@query("ha-input, ha-slider", true) private _input?:
|
||||
| HaInput
|
||||
| HTMLInputElement;
|
||||
|
||||
private _lastValue?: HaFormIntegerData;
|
||||
@@ -89,28 +89,30 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: ""}
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
helperPersistent
|
||||
.value=${this.data !== undefined ? this.data : ""}
|
||||
.hint=${this.helper}
|
||||
.value=${this.data !== undefined ? this.data.toString() : ""}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.schema.required}
|
||||
.autoValidate=${this.schema.required}
|
||||
.suffix=${this.schema.description?.suffix}
|
||||
.validationMessage=${this.schema.required
|
||||
? this.localize?.("ui.common.error_required")
|
||||
: undefined}
|
||||
@input=${this._valueChanged}
|
||||
></ha-textfield>
|
||||
>
|
||||
${this.schema.description?.suffix
|
||||
? html`<span slot="end">${this.schema.description.suffix}</span>`
|
||||
: nothing}
|
||||
</ha-input>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -167,8 +169,8 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _valueChanged(ev: Event) {
|
||||
const source = ev.target as HaTextField | HTMLInputElement;
|
||||
private _valueChanged(ev: InputEvent) {
|
||||
const source = ev.target as HaInput | HTMLInputElement;
|
||||
const rawValue = source.value;
|
||||
|
||||
let value: number | undefined;
|
||||
@@ -201,9 +203,6 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
ha-slider {
|
||||
flex: 1;
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -199,11 +199,6 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
padding-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-icon-button {
|
||||
color: var(--input-dropdown-icon-color);
|
||||
position: absolute;
|
||||
|
||||
@@ -76,10 +76,12 @@ export class HaGauge extends LitElement {
|
||||
const arcRadius = 40;
|
||||
const arcLength = Math.PI * arcRadius;
|
||||
const valueAngle = getAngle(this.value, this.min, this.max);
|
||||
const strokeOffset = arcLength * (1 - valueAngle / 180);
|
||||
const strokeOffset = this._updated
|
||||
? arcLength * (1 - valueAngle / 180)
|
||||
: arcLength;
|
||||
|
||||
return svg`
|
||||
<svg viewBox="-50 -50 100 60" class="gauge">
|
||||
<svg viewBox="-50 -50 100 55" class="gauge">
|
||||
<path
|
||||
class="levels-base"
|
||||
d="M -40 0 A 40 40 0 0 1 40 0"
|
||||
@@ -181,7 +183,7 @@ export class HaGauge extends LitElement {
|
||||
<text
|
||||
class="value-text"
|
||||
x="0"
|
||||
y="-10"
|
||||
y="-5"
|
||||
dominant-baseline="middle"
|
||||
text-anchor="middle"
|
||||
>
|
||||
@@ -222,22 +224,22 @@ export class HaGauge extends LitElement {
|
||||
.levels-base {
|
||||
fill: none;
|
||||
stroke: var(--primary-background-color);
|
||||
stroke-width: 10;
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.level {
|
||||
fill: none;
|
||||
stroke-width: 10;
|
||||
stroke-width: 8;
|
||||
stroke-linecap: butt;
|
||||
}
|
||||
|
||||
.value {
|
||||
fill: none;
|
||||
stroke-width: 10;
|
||||
stroke-width: 8;
|
||||
stroke: var(--gauge-color);
|
||||
stroke-linecap: round;
|
||||
transition: all 1s ease 0s;
|
||||
transition: stroke-dashoffset 1s ease 0s;
|
||||
}
|
||||
|
||||
.needle {
|
||||
@@ -249,6 +251,7 @@ export class HaGauge extends LitElement {
|
||||
}
|
||||
|
||||
.value-text {
|
||||
font-size: var(--ha-font-size-l);
|
||||
fill: var(--primary-text-color);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiPlaylistPlus } from "@mdi/js";
|
||||
import {
|
||||
css,
|
||||
@@ -14,10 +13,8 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { authContext } from "../data/context";
|
||||
import { PickerMixin } from "../mixins/picker-mixin";
|
||||
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
|
||||
import { isIosApp } from "../util/is_ios";
|
||||
import "./ha-bottom-sheet";
|
||||
import "./ha-button";
|
||||
import "./ha-combo-box-item";
|
||||
@@ -113,9 +110,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
@state()
|
||||
@consume({ context: authContext, subscribe: true })
|
||||
private auth?: ContextType<typeof authContext>;
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// @state()
|
||||
// @consume({ context: authContext, subscribe: true })
|
||||
// private auth?: ContextType<typeof authContext>;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
@@ -316,15 +314,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
this._comboBox?.setFieldValue(this._initialFieldValue);
|
||||
this._initialFieldValue = undefined;
|
||||
}
|
||||
if (this.auth?.external && isIosApp(this.auth.external)) {
|
||||
this.auth.external.fireMessage({
|
||||
type: "focus_element",
|
||||
payload: {
|
||||
element_id: "combo-box",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// if (this.auth?.external && isIosApp(this.auth.external)) {
|
||||
// this.auth.external.fireMessage({
|
||||
// type: "focus_element",
|
||||
// payload: {
|
||||
// element_id: "combo-box",
|
||||
// },
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
this._comboBox?.focus();
|
||||
});
|
||||
|
||||
@@ -32,12 +32,6 @@ export class HaGridSizeEditor extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public step = 1;
|
||||
|
||||
@property({ type: Boolean, attribute: "rows-disabled" })
|
||||
public rowsDisabled?: boolean;
|
||||
|
||||
@property({ type: Boolean, attribute: "columns-disabled" })
|
||||
public columnsDisabled?: boolean;
|
||||
|
||||
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
|
||||
|
||||
protected willUpdate(changedProperties) {
|
||||
@@ -47,16 +41,15 @@ export class HaGridSizeEditor extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const disabledColumns =
|
||||
this.columnsDisabled ||
|
||||
(this.columnMin !== undefined && this.columnMin === this.columnMax);
|
||||
const disabledRows =
|
||||
this.rowsDisabled ||
|
||||
(this.rowMin !== undefined && this.rowMin === this.rowMax);
|
||||
|
||||
const autoHeight = this._localValue?.rows === "auto";
|
||||
const fullWidth = this._localValue?.columns === "full";
|
||||
|
||||
const disabledColumns =
|
||||
fullWidth ||
|
||||
(this.columnMin !== undefined && this.columnMin === this.columnMax);
|
||||
const disabledRows =
|
||||
autoHeight || (this.rowMin !== undefined && this.rowMin === this.rowMax);
|
||||
|
||||
const rowMin = this.rowMin ?? 1;
|
||||
const rowMax = this.rowMax ?? this.rows;
|
||||
const columnMin = Math.ceil((this.columnMin ?? 1) / this.step) * this.step;
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { mdiDeleteOutline, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-button";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-input-helper-text";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
|
||||
@customElement("ha-multi-textfield")
|
||||
class HaMultiTextField extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ attribute: false }) public helper?: string;
|
||||
|
||||
@property({ attribute: false }) public inputType?: string;
|
||||
|
||||
@property({ attribute: false }) public inputSuffix?: string;
|
||||
|
||||
@property({ attribute: false }) public inputPrefix?: string;
|
||||
|
||||
@property({ attribute: false }) public autocomplete?: string;
|
||||
|
||||
@property({ attribute: false }) public addLabel?: string;
|
||||
|
||||
@property({ attribute: false }) public removeLabel?: string;
|
||||
|
||||
@property({ attribute: "item-index", type: Boolean })
|
||||
public itemIndex = false;
|
||||
|
||||
@property({ type: Number }) public max?: number;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this._items.map((item, index) => {
|
||||
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
|
||||
return html`
|
||||
<div class="layout horizontal center-center row">
|
||||
<ha-textfield
|
||||
.suffix=${this.inputSuffix}
|
||||
.prefix=${this.inputPrefix}
|
||||
.type=${this.inputType}
|
||||
.autocomplete=${this.autocomplete}
|
||||
.disabled=${this.disabled}
|
||||
dialogInitialFocus=${index}
|
||||
.index=${index}
|
||||
class="flex-auto"
|
||||
.label=${`${this.label ? `${this.label}${indexSuffix}` : ""}`}
|
||||
.value=${item}
|
||||
?data-last=${index === this._items.length - 1}
|
||||
@input=${this._editItem}
|
||||
@keydown=${this._keyDown}
|
||||
></ha-textfield>
|
||||
<ha-icon-button
|
||||
.disabled=${this.disabled}
|
||||
.index=${index}
|
||||
slot="navigationIcon"
|
||||
.label=${this.removeLabel ??
|
||||
this.hass?.localize("ui.common.remove") ??
|
||||
"Remove"}
|
||||
@click=${this._removeItem}
|
||||
.path=${mdiDeleteOutline}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="layout horizontal">
|
||||
<ha-button
|
||||
size="small"
|
||||
appearance="filled"
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled ||
|
||||
(this.max != null && this._items.length >= this.max)}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.addLabel ??
|
||||
(this.label
|
||||
? this.hass?.localize("ui.components.multi-textfield.add_item", {
|
||||
item: this.label,
|
||||
})
|
||||
: this.hass?.localize("ui.common.add")) ??
|
||||
"Add"}
|
||||
</ha-button>
|
||||
</div>
|
||||
${this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private get _items() {
|
||||
return this.value ?? [];
|
||||
}
|
||||
|
||||
private async _addItem() {
|
||||
if (this.max != null && this._items.length >= this.max) {
|
||||
return;
|
||||
}
|
||||
const items = [...this._items, ""];
|
||||
this._fireChanged(items);
|
||||
await this.updateComplete;
|
||||
const field = this.shadowRoot?.querySelector(`ha-textfield[data-last]`) as
|
||||
| HaTextField
|
||||
| undefined;
|
||||
field?.focus();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
const index = (ev.target as any).index;
|
||||
const items = [...this._items];
|
||||
items[index] = (ev.target as any).value;
|
||||
this._fireChanged(items);
|
||||
}
|
||||
|
||||
private async _keyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter") {
|
||||
ev.stopPropagation();
|
||||
this._addItem();
|
||||
}
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
const index = (ev.target as any).index;
|
||||
const items = [...this._items];
|
||||
items.splice(index, 1);
|
||||
this._fireChanged(items);
|
||||
}
|
||||
|
||||
private _fireChanged(value) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
}
|
||||
ha-icon-button {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-multi-textfield": HaMultiTextField;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { OutlinedTextField } from "@material/web/textfield/internal/outlined-text-field";
|
||||
import { styles } from "@material/web/textfield/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/textfield/internal/shared-styles";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { literal } from "lit/static-html";
|
||||
import "./ha-outlined-field";
|
||||
|
||||
@customElement("ha-outlined-text-field")
|
||||
export class HaOutlinedTextField extends OutlinedTextField {
|
||||
protected readonly fieldTag = literal`ha-outlined-field`;
|
||||
|
||||
static override styles = [
|
||||
sharedStyles,
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
--md-sys-color-on-surface: var(--primary-text-color);
|
||||
--md-sys-color-primary: var(--primary-text-color);
|
||||
--md-outlined-text-field-input-text-color: var(--primary-text-color);
|
||||
--md-sys-color-on-surface-variant: var(--secondary-text-color);
|
||||
--md-outlined-field-outline-color: var(--outline-color);
|
||||
--md-outlined-field-focus-outline-color: var(--primary-color);
|
||||
--md-outlined-field-hover-outline-color: var(--outline-hover-color);
|
||||
}
|
||||
:host([dense]) {
|
||||
--md-outlined-field-top-space: 5.5px;
|
||||
--md-outlined-field-bottom-space: 5.5px;
|
||||
--md-outlined-field-container-shape-start-start: 10px;
|
||||
--md-outlined-field-container-shape-start-end: 10px;
|
||||
--md-outlined-field-container-shape-end-end: 10px;
|
||||
--md-outlined-field-container-shape-end-start: 10px;
|
||||
--md-outlined-field-focus-outline-width: 1px;
|
||||
--md-outlined-field-with-leading-content-leading-space: 8px;
|
||||
--md-outlined-field-with-trailing-content-trailing-space: 8px;
|
||||
--md-outlined-field-content-space: 8px;
|
||||
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
|
||||
}
|
||||
.input {
|
||||
font-family: var(--ha-font-family-body);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-outlined-text-field": HaOutlinedTextField;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiClose, mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
|
||||
import { mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
@@ -30,8 +30,8 @@ import "./ha-combo-box-item";
|
||||
import "./ha-icon";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
export const DEFAULT_SEARCH_KEYS: FuseWeightedKey[] = [
|
||||
{
|
||||
@@ -159,7 +159,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
|
||||
|
||||
@query("ha-textfield") private _searchFieldElement?: HaTextField;
|
||||
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
|
||||
|
||||
@state()
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
@@ -226,19 +226,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
"Search | Add custom value")
|
||||
: (this.localize?.("ui.common.search") ?? "Search"));
|
||||
|
||||
return html`<ha-textfield
|
||||
.label=${searchLabel}
|
||||
return html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.placeholder=${searchLabel}
|
||||
@blur=${this._resetSelectedItem}
|
||||
@input=${this._filterChanged}
|
||||
.iconTrailing=${this.clearable && !!this._search}
|
||||
>
|
||||
<ha-icon-button
|
||||
@click=${this._clearSearch}
|
||||
slot="trailingIcon"
|
||||
.label=${this.localize?.("ui.common.clear") || "Clear"}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
</ha-textfield>
|
||||
</ha-input-search>
|
||||
${this._renderSectionButtons()}
|
||||
${this.sections?.length
|
||||
? html`
|
||||
@@ -455,21 +449,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
fireEvent(this, "index-selected", { index, newTab });
|
||||
}
|
||||
|
||||
private _clearSearch = () => {
|
||||
if (this._searchFieldElement) {
|
||||
this._searchFieldElement.value = "";
|
||||
this._searchFieldElement.dispatchEvent(new Event("input"));
|
||||
}
|
||||
};
|
||||
|
||||
private _fuseIndex = memoizeOne(
|
||||
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
|
||||
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
|
||||
);
|
||||
|
||||
private _filterChanged = (ev: Event) => {
|
||||
const textfield = ev.target as HaTextField;
|
||||
const searchString = textfield.value.trim();
|
||||
private _filterChanged = (ev: InputEvent) => {
|
||||
const textfield = ev.target as HaInputSearch;
|
||||
const searchString = (textfield.value ?? "").trim();
|
||||
this._search = searchString;
|
||||
|
||||
if (this.sections?.length) {
|
||||
@@ -810,12 +797,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
--text-field-padding-end: 0;
|
||||
}
|
||||
|
||||
ha-textfield {
|
||||
ha-input-search {
|
||||
padding: 0 var(--ha-space-3);
|
||||
margin-bottom: var(--ha-space-3);
|
||||
}
|
||||
|
||||
:host([mode="dialog"]) ha-textfield {
|
||||
:host([mode="dialog"]) ha-input-search {
|
||||
padding: 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
@@ -929,7 +915,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
padding: var(--ha-space-1) var(--ha-space-4);
|
||||
}
|
||||
|
||||
:host([mode="dialog"]) ha-textfield {
|
||||
:host([mode="dialog"]) ha-input-search {
|
||||
padding: 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,14 +120,12 @@ export class HaPickerField extends PickerMixin(LitElement) {
|
||||
return [
|
||||
css`
|
||||
ha-combo-box-item[disabled] {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-fill-color,
|
||||
whitesmoke
|
||||
);
|
||||
background-color: var(--ha-color-form-background-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
ha-combo-box-item {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
background-color: var(--ha-color-form-background);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
|
||||
@@ -17,8 +17,8 @@ import "./ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "./ha-dropdown";
|
||||
import "./ha-dropdown-item";
|
||||
import "./ha-spinner";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
import "./input/ha-input";
|
||||
import type { HaInput } from "./input/ha-input";
|
||||
|
||||
prepareZXingModule({
|
||||
overrides: {
|
||||
@@ -64,7 +64,7 @@ class HaQrScanner extends LitElement {
|
||||
|
||||
@query("#canvas-container", true) private _canvasContainer?: HTMLDivElement;
|
||||
|
||||
@query("ha-textfield") private _manualInput?: HaTextField;
|
||||
@query("ha-input") private _manualInput?: HaInput;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
@@ -153,13 +153,13 @@ class HaQrScanner extends LitElement {
|
||||
</ha-alert>
|
||||
<p>${this.hass.localize("ui.components.qr-scanner.manual_input")}</p>
|
||||
<div class="row">
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.qr-scanner.enter_qr_code"
|
||||
)}
|
||||
@keyup=${this._manualKeyup}
|
||||
@paste=${this._manualPaste}
|
||||
></ha-textfield>
|
||||
></ha-input>
|
||||
<ha-button @click=${this._manualSubmit}>
|
||||
${this.hass.localize("ui.common.submit")}
|
||||
</ha-button>
|
||||
@@ -242,7 +242,7 @@ class HaQrScanner extends LitElement {
|
||||
|
||||
private _manualKeyup(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter") {
|
||||
this._qrCodeScanned((ev.target as HaTextField).value);
|
||||
this._qrCodeScanned((ev.target as HaInput).value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ class HaQrScanner extends LitElement {
|
||||
}
|
||||
|
||||
private _manualSubmit() {
|
||||
this._qrCodeScanned(this._manualInput!.value);
|
||||
this._qrCodeScanned(this._manualInput!.value ?? "");
|
||||
}
|
||||
|
||||
private _handleDropdownSelect(ev: HaDropdownSelectEvent) {
|
||||
@@ -382,7 +382,7 @@ class HaQrScanner extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-textfield {
|
||||
ha-input {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
|
||||
@@ -3,13 +3,10 @@ import { customElement, property } from "lit/decorators";
|
||||
import { hex2rgb, rgb2hex } from "../../common/color/convert-color";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ColorRGBSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-textfield";
|
||||
import "../input/ha-input";
|
||||
|
||||
@customElement("ha-selector-color_rgb")
|
||||
export class HaColorRGBSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: ColorRGBSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -24,16 +21,15 @@ export class HaColorRGBSelector extends LitElement {
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
type="color"
|
||||
helperPersistent
|
||||
.value=${this.value ? rgb2hex(this.value as any) : ""}
|
||||
.label=${this.label || ""}
|
||||
.required=${this.required}
|
||||
.helper=${this.helper}
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._valueChanged}
|
||||
></ha-textfield>
|
||||
></ha-input>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -50,14 +46,10 @@ export class HaColorRGBSelector extends LitElement {
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
ha-textfield {
|
||||
--text-field-padding-top: 8px;
|
||||
--text-field-padding-bottom: 8px;
|
||||
--text-field-padding-start: 8px;
|
||||
--text-field-padding-end: 8px;
|
||||
ha-input {
|
||||
min-width: 75px;
|
||||
flex-grow: 1;
|
||||
margin: 0 4px;
|
||||
margin: 0 var(--ha-space-1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { NumberSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-slider";
|
||||
import "../ha-textfield";
|
||||
import type { HaTextField } from "../ha-textfield";
|
||||
import "../input/ha-input";
|
||||
import type { HaInput } from "../input/ha-input";
|
||||
|
||||
@customElement("ha-selector-number")
|
||||
export class HaNumberSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: NumberSelector;
|
||||
|
||||
@property({ type: Number }) public value?: number;
|
||||
@@ -31,7 +27,7 @@ export class HaNumberSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-textfield", true) private _input?: HaTextField | HTMLInputElement;
|
||||
@query("ha-input", true) private _input?: HaInput;
|
||||
|
||||
private _valueStr = "";
|
||||
|
||||
@@ -99,29 +95,30 @@ export class HaNumberSelector extends LitElement {
|
||||
</ha-slider>
|
||||
`
|
||||
: nothing}
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
.inputMode=${this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"}
|
||||
.label=${!isBox ? undefined : this.label}
|
||||
.placeholder=${this.placeholder}
|
||||
class=${classMap({ single: isBox })}
|
||||
.placeholder=${this.placeholder !== undefined
|
||||
? this.placeholder.toString()
|
||||
: ""}
|
||||
class=${isBox ? "single" : ""}
|
||||
.min=${this.selector.number?.min}
|
||||
.max=${this.selector.number?.max}
|
||||
.value=${this._valueStr ?? ""}
|
||||
.step=${this.selector.number?.step ?? 1}
|
||||
helperPersistent
|
||||
.helper=${isBox ? this.helper : undefined}
|
||||
.hint=${isBox ? this.helper : undefined}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.suffix=${unit}
|
||||
type="number"
|
||||
autoValidate
|
||||
?no-spinner=${!isBox}
|
||||
.withoutSpinButtons=${!isBox}
|
||||
@input=${this._handleInputChange}
|
||||
>
|
||||
</ha-textfield>
|
||||
${unit ? html`<span slot="end">${unit}</span>` : nothing}
|
||||
</ha-input>
|
||||
</div>
|
||||
${!isBox && this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
@@ -166,11 +163,10 @@ export class HaNumberSelector extends LitElement {
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
ha-textfield {
|
||||
--ha-textfield-input-width: 40px;
|
||||
ha-input::part(wa-input) {
|
||||
width: 40px;
|
||||
}
|
||||
.single {
|
||||
--ha-textfield-input-width: unset;
|
||||
ha-input.single {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
506
src/components/ha-selector/ha-selector-numeric-threshold.ts
Normal file
506
src/components/ha-selector/ha-selector-numeric-threshold.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type {
|
||||
NumericThresholdSelector,
|
||||
ThresholdMode,
|
||||
} from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-button-toggle-group";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-select";
|
||||
import "./ha-selector";
|
||||
|
||||
const iconThresholdAbove =
|
||||
"M 2 2 L 2 22 L 22 22 L 22 20 L 4 20 L 4 2 L 2 2 z M 17.976562 6 C 17.965863 6.00017 17.951347 6.0014331 17.935547 6.0019531 C 17.903847 6.0030031 17.862047 6.0043225 17.810547 6.0078125 C 17.707247 6.0148425 17.564772 6.0273144 17.388672 6.0527344 C 17.036572 6.1035743 16.54829 6.2035746 15.962891 6.3964844 C 14.788292 6.783584 13.232027 7.5444846 11.611328 9.0332031 C 10.753918 9.820771 9.8854345 10.808987 9.0449219 12.042969 C 7.881634 12.257047 7 13.274809 7 14.5 C 7 15.880699 8.1192914 17 9.5 17 C 10.880699 17 12 15.880699 12 14.5 C 12 13.732663 11.653544 13.046487 11.109375 12.587891 C 11.732682 11.74814 12.359503 11.061942 12.964844 10.505859 C 14.359842 9.2245207 15.662945 8.6023047 16.589844 8.296875 C 17.054643 8.1437252 17.426428 8.0689231 17.673828 8.0332031 C 17.797428 8.0153531 17.891466 8.0076962 17.947266 8.0039062 C 17.974966 8.0020263 17.992753 8.0003 18.001953 8 L 17.998047 6 L 17.976562 6 z";
|
||||
|
||||
const iconThresholdBelow =
|
||||
"M 2 2 L 2 22 L 22 22 L 22 20 L 4 20 L 4 2 L 2 2 z M 9.5 7 C 8.1192914 7 7 8.1192914 7 9.5 C 7 10.880699 8.1192914 12 9.5 12 C 9.598408 12 9.6955741 11.993483 9.7910156 11.982422 C 10.39444 12.754246 11.005767 13.410563 11.611328 13.966797 C 13.232027 15.455495 14.788292 16.216416 15.962891 16.603516 C 16.54829 16.796415 17.036572 16.896366 17.388672 16.947266 C 17.564772 16.972666 17.707247 16.985188 17.810547 16.992188 C 17.862047 16.995687 17.903847 16.997047 17.935547 16.998047 C 17.951347 16.998547 17.965863 16.9998 17.976562 17 L 17.998047 17 L 18.001953 15 C 17.992753 14.9997 17.974966 14.999947 17.947266 14.998047 C 17.891466 14.994247 17.797428 14.984597 17.673828 14.966797 C 17.426428 14.931097 17.054643 14.856325 16.589844 14.703125 C 15.662945 14.397725 14.359842 13.775439 12.964844 12.494141 C 12.496227 12.063656 12.015935 11.551532 11.533203 10.955078 C 11.826929 10.545261 12 10.042666 12 9.5 C 12 8.1192914 10.880699 7 9.5 7 z";
|
||||
|
||||
const iconThresholdBetween =
|
||||
"M 2 2 L 2 22 L 22 22 L 22 20 L 4 20 L 4 2 L 2 2 z M 16.5 4 C 15.119301 4 14 5.1192914 14 6.5 C 14 6.8572837 14.075904 7.196497 14.210938 7.5039062 C 13.503071 8.3427071 12.800578 9.3300361 12.130859 10.501953 C 11.718781 11.223082 11.287475 11.849823 10.845703 12.394531 C 10.457136 12.145771 9.9956073 12 9.5 12 C 8.1192914 12 7 13.119301 7 14.5 C 7 15.880699 8.1192914 17 9.5 17 C 10.880699 17 12 15.880699 12 14.5 C 12 14.38201 11.990422 14.26598 11.974609 14.152344 C 12.636605 13.409426 13.276156 12.531884 13.869141 11.494141 C 14.462491 10.455789 15.073208 9.5905169 15.681641 8.8613281 C 15.938115 8.9501682 16.213303 9 16.5 9 C 17.880699 9 19 7.8807086 19 6.5 C 19 5.1192914 17.880699 4 16.5 4 z";
|
||||
|
||||
const iconThresholdOutside =
|
||||
"M 2 2 L 2 22 L 22 22 L 22 20 L 4 20 L 4 19.046875 C 4.226574 19.041905 4.4812768 19.028419 4.7597656 19 C 5.8832145 18.8854 7.4011147 18.537974 9.0019531 17.609375 L 8.8847656 17.408203 C 9.320466 17.777433 9.8841605 18 10.5 18 C 11.880699 18 13 16.880699 13 15.5 C 13 14.119301 11.880699 13 10.5 13 C 9.1192914 13 8 14.119301 8 15.5 C 8 15.654727 8.0141099 15.806171 8.0410156 15.953125 L 7.9980469 15.876953 C 6.6882482 16.636752 5.4555097 16.918066 4.5566406 17.009766 C 4.3512557 17.030705 4.166436 17.040275 4 17.044922 L 4 2 L 2 2 z M 21.976562 4 C 21.965863 4.00017 21.951347 4.0014331 21.935547 4.0019531 C 21.903847 4.0030031 21.862047 4.0043225 21.810547 4.0078125 C 21.707247 4.0148425 21.564772 4.0273144 21.388672 4.0527344 C 21.036572 4.1035743 20.54829 4.2035846 19.962891 4.3964844 C 19.34193 4.6011277 18.613343 4.9149715 17.826172 5.3808594 C 17.441793 5.1398775 16.987134 5 16.5 5 C 15.119301 5 14 6.1192914 14 7.5 C 14 8.8807086 15.119301 10 16.5 10 C 17.880699 10 19 8.8807086 19 7.5 C 19 7.3403872 18.983669 7.1845035 18.955078 7.0332031 C 19.569666 6.6795942 20.126994 6.4493921 20.589844 6.296875 C 21.054643 6.1437252 21.426428 6.0689231 21.673828 6.0332031 C 21.797428 6.0153531 21.891466 6.0076962 21.947266 6.0039062 C 21.974966 6.0020263 21.992753 6.0003 22.001953 6 L 21.998047 4 L 21.976562 4 z";
|
||||
|
||||
type ThresholdType = "above" | "below" | "between" | "outside" | "any";
|
||||
|
||||
interface ThresholdValueEntry {
|
||||
active_choice?: string;
|
||||
number?: number;
|
||||
entity?: string;
|
||||
unit_of_measurement?: string;
|
||||
}
|
||||
|
||||
interface NumericThresholdValue {
|
||||
type: ThresholdType;
|
||||
value?: ThresholdValueEntry;
|
||||
value_min?: ThresholdValueEntry;
|
||||
value_max?: ThresholdValueEntry;
|
||||
}
|
||||
|
||||
const DEFAULT_TYPE: Record<ThresholdMode, ThresholdType> = {
|
||||
crossed: "above",
|
||||
changed: "any",
|
||||
is: "above",
|
||||
};
|
||||
|
||||
@customElement("ha-selector-numeric_threshold")
|
||||
export class HaNumericThresholdSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: NumericThresholdSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: NumericThresholdValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@state() private _type?: ThresholdType;
|
||||
|
||||
private _getMode(): ThresholdMode {
|
||||
return this.selector.numeric_threshold?.mode ?? "crossed";
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (changedProperties.has("value") || changedProperties.has("selector")) {
|
||||
const mode = this._getMode();
|
||||
this._type = this.value?.type || DEFAULT_TYPE[mode];
|
||||
}
|
||||
}
|
||||
|
||||
private _getUnitOptions() {
|
||||
return this.selector.numeric_threshold?.unit_of_measurement;
|
||||
}
|
||||
|
||||
private _getEntityFilter() {
|
||||
const baseFilter = this.selector.numeric_threshold?.entity;
|
||||
const configuredUnits =
|
||||
this.selector.numeric_threshold?.unit_of_measurement;
|
||||
|
||||
if (!configuredUnits) {
|
||||
return baseFilter;
|
||||
}
|
||||
|
||||
if (Array.isArray(baseFilter)) {
|
||||
return baseFilter.map((f) => ({
|
||||
...f,
|
||||
unit_of_measurement: configuredUnits,
|
||||
}));
|
||||
}
|
||||
|
||||
if (baseFilter) {
|
||||
return { ...baseFilter, unit_of_measurement: configuredUnits };
|
||||
}
|
||||
|
||||
return { unit_of_measurement: configuredUnits };
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const mode = this._getMode();
|
||||
const type = this._type || DEFAULT_TYPE[mode];
|
||||
const showSingleValue = type === "above" || type === "below";
|
||||
const showRangeValues = type === "between" || type === "outside";
|
||||
const unitOptions = this._getUnitOptions();
|
||||
|
||||
const typeOptions = this._buildTypeOptions(this.hass.localize, mode);
|
||||
|
||||
const choiceToggleButtons = [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.components.selectors.numeric_threshold.number"
|
||||
),
|
||||
value: "number",
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.components.selectors.numeric_threshold.entity"
|
||||
),
|
||||
value: "entity",
|
||||
},
|
||||
];
|
||||
|
||||
// Value-row label for single-value types (above/below).
|
||||
const singleValueLabel = this.hass.localize(
|
||||
`ui.components.selectors.numeric_threshold.${mode}.${type as "above" | "below"}`
|
||||
);
|
||||
|
||||
// Determine which type-select label to use per mode
|
||||
const typeSelectLabel = this.hass.localize(
|
||||
`ui.components.selectors.numeric_threshold.${mode}.type`
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
${this.label
|
||||
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
|
||||
: nothing}
|
||||
<div class="inputs">
|
||||
<ha-select
|
||||
.label=${typeSelectLabel}
|
||||
.value=${type}
|
||||
.options=${typeOptions}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._typeChanged}
|
||||
></ha-select>
|
||||
|
||||
${showSingleValue
|
||||
? this._renderValueRow(
|
||||
singleValueLabel,
|
||||
this.value?.value,
|
||||
this._valueChanged,
|
||||
this._valueChoiceChanged,
|
||||
this._unitChanged,
|
||||
unitOptions,
|
||||
choiceToggleButtons
|
||||
)
|
||||
: nothing}
|
||||
${showRangeValues
|
||||
? html`
|
||||
${this._renderValueRow(
|
||||
this.hass.localize(
|
||||
"ui.components.selectors.numeric_threshold.from"
|
||||
),
|
||||
this.value?.value_min,
|
||||
this._valueMinChanged,
|
||||
this._valueMinChoiceChanged,
|
||||
this._unitMinChanged,
|
||||
unitOptions,
|
||||
choiceToggleButtons
|
||||
)}
|
||||
${this._renderValueRow(
|
||||
this.hass.localize(
|
||||
"ui.components.selectors.numeric_threshold.to"
|
||||
),
|
||||
this.value?.value_max,
|
||||
this._valueMaxChanged,
|
||||
this._valueMaxChoiceChanged,
|
||||
this._unitMaxChanged,
|
||||
unitOptions,
|
||||
choiceToggleButtons
|
||||
)}
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
${this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _buildTypeOptions = memoizeOne(
|
||||
(localize: HomeAssistant["localize"], mode: ThresholdMode) => {
|
||||
const baseOptions = (
|
||||
[
|
||||
{ value: "above", iconPath: iconThresholdAbove },
|
||||
{ value: "below", iconPath: iconThresholdBelow },
|
||||
{ value: "between", iconPath: iconThresholdBetween },
|
||||
{ value: "outside", iconPath: iconThresholdOutside },
|
||||
] as const
|
||||
).map(({ value, iconPath }) => ({
|
||||
value,
|
||||
iconPath,
|
||||
label: localize(
|
||||
`ui.components.selectors.numeric_threshold.${mode}.${value}`
|
||||
),
|
||||
}));
|
||||
|
||||
if (mode !== "changed") {
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: "any",
|
||||
label: localize(
|
||||
"ui.components.selectors.numeric_threshold.changed.any"
|
||||
),
|
||||
},
|
||||
...baseOptions,
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
private _renderUnitSelect(
|
||||
entry: ThresholdValueEntry | undefined,
|
||||
handler: (ev: CustomEvent) => void,
|
||||
unitOptions: readonly string[]
|
||||
) {
|
||||
if (unitOptions.length <= 1) {
|
||||
return nothing;
|
||||
}
|
||||
const mappedUnitOptions = unitOptions.map((unit) => ({
|
||||
value: unit,
|
||||
label: unit,
|
||||
}));
|
||||
const unitLabel = this.hass.localize(
|
||||
"ui.components.selectors.numeric_threshold.unit"
|
||||
);
|
||||
return html`
|
||||
<ha-select
|
||||
class="unit-selector"
|
||||
.label=${unitLabel}
|
||||
.value=${entry?.unit_of_measurement || unitOptions[0]}
|
||||
.options=${mappedUnitOptions}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${handler}
|
||||
></ha-select>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderValueRow(
|
||||
rowLabel: string,
|
||||
entry: ThresholdValueEntry | undefined,
|
||||
onValueChanged: (ev: CustomEvent) => void,
|
||||
onChoiceChanged: (ev: CustomEvent) => void,
|
||||
onUnitChanged: (ev: CustomEvent) => void,
|
||||
unitOptions: readonly string[] | undefined,
|
||||
choiceToggleButtons: { label: string; value: string }[]
|
||||
) {
|
||||
const activeChoice = entry?.active_choice ?? "number";
|
||||
const isEntity = activeChoice === "entity";
|
||||
const showUnit = !isEntity && !!unitOptions && unitOptions.length > 1;
|
||||
const innerValue = isEntity ? entry?.entity : entry?.number;
|
||||
const effectiveUnit = entry?.unit_of_measurement || unitOptions?.[0];
|
||||
const numberSelector = {
|
||||
number: {
|
||||
...this.selector.numeric_threshold?.number,
|
||||
...(effectiveUnit ? { unit_of_measurement: effectiveUnit } : {}),
|
||||
},
|
||||
};
|
||||
const entitySelector = {
|
||||
entity: {
|
||||
filter: this._getEntityFilter(),
|
||||
},
|
||||
};
|
||||
const innerSelector = isEntity ? entitySelector : numberSelector;
|
||||
return html`
|
||||
<div class="value-row">
|
||||
<div class="value-header">
|
||||
${rowLabel
|
||||
? html`<span class="value-label"
|
||||
>${rowLabel}${this.required ? "*" : ""}</span
|
||||
>`
|
||||
: nothing}
|
||||
<ha-button-toggle-group
|
||||
size="small"
|
||||
.buttons=${choiceToggleButtons}
|
||||
.active=${activeChoice}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${onChoiceChanged}
|
||||
></ha-button-toggle-group>
|
||||
</div>
|
||||
<div class="value-inputs">
|
||||
<ha-selector
|
||||
class="value-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${innerSelector}
|
||||
.value=${innerValue}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
@value-changed=${onValueChanged}
|
||||
></ha-selector>
|
||||
${showUnit
|
||||
? this._renderUnitSelect(entry, onUnitChanged, unitOptions!)
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _typeChanged(ev: CustomEvent) {
|
||||
const value = ev.detail?.value;
|
||||
if (!value || value === this._type) {
|
||||
return;
|
||||
}
|
||||
this._type = value as ThresholdType;
|
||||
|
||||
const newValue: NumericThresholdValue = {
|
||||
type: this._type,
|
||||
};
|
||||
|
||||
// Preserve values when switching between similar types
|
||||
if (this._type === "above" || this._type === "below") {
|
||||
newValue.value = this.value?.value ?? this.value?.value_min;
|
||||
} else if (this._type === "between" || this._type === "outside") {
|
||||
newValue.value_min = this.value?.value_min ?? this.value?.value;
|
||||
newValue.value_max = this.value?.value_max;
|
||||
}
|
||||
// "any" type has no value fields
|
||||
|
||||
fireEvent(this, "value-changed", { value: newValue });
|
||||
}
|
||||
|
||||
private _choiceChanged(
|
||||
field: "value" | "value_min" | "value_max",
|
||||
ev: CustomEvent
|
||||
) {
|
||||
ev.stopPropagation();
|
||||
const choice = ev.detail?.value as string;
|
||||
const defaultUnit = this._getUnitOptions()?.[0];
|
||||
const entry: ThresholdValueEntry = {
|
||||
...this.value?.[field],
|
||||
active_choice: choice,
|
||||
};
|
||||
if (choice !== "entity" && !entry.unit_of_measurement && defaultUnit) {
|
||||
entry.unit_of_measurement = defaultUnit;
|
||||
}
|
||||
const defaultType = field === "value" ? "above" : "between";
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
type: this._type || defaultType,
|
||||
[field]: entry,
|
||||
...(field === "value"
|
||||
? { value_min: undefined, value_max: undefined }
|
||||
: { value: undefined }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _valueChoiceChanged = (ev: CustomEvent) =>
|
||||
this._choiceChanged("value", ev);
|
||||
|
||||
private _valueMinChoiceChanged = (ev: CustomEvent) =>
|
||||
this._choiceChanged("value_min", ev);
|
||||
|
||||
private _valueMaxChoiceChanged = (ev: CustomEvent) =>
|
||||
this._choiceChanged("value_max", ev);
|
||||
|
||||
// Called when the inner number/entity selector value changes
|
||||
private _entryChanged(
|
||||
field: "value" | "value_min" | "value_max",
|
||||
ev: CustomEvent
|
||||
) {
|
||||
ev.stopPropagation();
|
||||
const activeChoice = this.value?.[field]?.active_choice ?? "number";
|
||||
const defaultUnit = this._getUnitOptions()?.[0];
|
||||
const entry: ThresholdValueEntry = {
|
||||
...this.value?.[field],
|
||||
active_choice: activeChoice,
|
||||
[activeChoice]: ev.detail.value,
|
||||
};
|
||||
if (
|
||||
activeChoice !== "entity" &&
|
||||
!entry.unit_of_measurement &&
|
||||
defaultUnit
|
||||
) {
|
||||
entry.unit_of_measurement = defaultUnit;
|
||||
}
|
||||
const defaultType = field === "value" ? "above" : "between";
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
type: this._type || defaultType,
|
||||
[field]: entry,
|
||||
...(field === "value"
|
||||
? { value_min: undefined, value_max: undefined }
|
||||
: { value: undefined }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _valueChanged = (ev: CustomEvent) => this._entryChanged("value", ev);
|
||||
|
||||
private _valueMinChanged = (ev: CustomEvent) =>
|
||||
this._entryChanged("value_min", ev);
|
||||
|
||||
private _valueMaxChanged = (ev: CustomEvent) =>
|
||||
this._entryChanged("value_max", ev);
|
||||
|
||||
private _unitFieldChanged(
|
||||
field: "value" | "value_min" | "value_max",
|
||||
ev: CustomEvent
|
||||
) {
|
||||
const unit = ev.detail?.value;
|
||||
if (unit === this.value?.[field]?.unit_of_measurement) return;
|
||||
const activeChoice = this.value?.[field]?.active_choice ?? "number";
|
||||
const defaultType = field === "value" ? "above" : "between";
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
type: this._type || defaultType,
|
||||
[field]: {
|
||||
...this.value?.[field],
|
||||
active_choice: activeChoice,
|
||||
unit_of_measurement: unit || undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _unitChanged = (ev: CustomEvent) =>
|
||||
this._unitFieldChanged("value", ev);
|
||||
|
||||
private _unitMinChanged = (ev: CustomEvent) =>
|
||||
this._unitFieldChanged("value_min", ev);
|
||||
|
||||
private _unitMaxChanged = (ev: CustomEvent) =>
|
||||
this._unitFieldChanged("value_max", ev);
|
||||
|
||||
static styles = css`
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.inputs,
|
||||
.value-row {
|
||||
--ha-input-padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.value-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.value-inputs {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.value-selector {
|
||||
flex: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.unit-selector {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-numeric_threshold": HaNumericThresholdSelector;
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,7 @@ export class HaObjectSelector extends LitElement {
|
||||
schema: this._schema(this.selector),
|
||||
data: item,
|
||||
computeLabel: this._computeLabel,
|
||||
computeHelper: this._computeHelper,
|
||||
submitText: this.hass.localize("ui.common.save"),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { StringSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-multi-textfield";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-textarea";
|
||||
import "../ha-textfield";
|
||||
import "../input/ha-input";
|
||||
import "../input/ha-input-multi";
|
||||
|
||||
@customElement("ha-selector-text")
|
||||
export class HaTextSelector extends LitElement {
|
||||
@@ -30,9 +28,7 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@state() private _unmaskedPassword = false;
|
||||
|
||||
@query("ha-textfield, ha-textarea") private _input?: HTMLInputElement;
|
||||
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
@@ -49,8 +45,7 @@ export class HaTextSelector extends LitElement {
|
||||
protected render() {
|
||||
if (this.selector.text?.multiple) {
|
||||
return html`
|
||||
<ha-multi-textfield
|
||||
.hass=${this.hass}
|
||||
<ha-input-multi
|
||||
.value=${ensureArray(this.value ?? [])}
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label}
|
||||
@@ -61,7 +56,7 @@ export class HaTextSelector extends LitElement {
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
@value-changed=${this._handleChange}
|
||||
>
|
||||
</ha-multi-textfield>
|
||||
</ha-input-multi>
|
||||
`;
|
||||
}
|
||||
if (this.selector.text?.multiline) {
|
||||
@@ -81,45 +76,34 @@ export class HaTextSelector extends LitElement {
|
||||
autogrow
|
||||
></ha-textarea>`;
|
||||
}
|
||||
return html`<ha-textfield
|
||||
.name=${this.name}
|
||||
.value=${this.value || ""}
|
||||
.placeholder=${this.placeholder || ""}
|
||||
.helper=${this.helper}
|
||||
helperPersistent
|
||||
.disabled=${this.disabled}
|
||||
.type=${this._unmaskedPassword ? "text" : this.selector.text?.type}
|
||||
@input=${this._handleChange}
|
||||
@change=${this._handleChange}
|
||||
.label=${this.label || ""}
|
||||
.prefix=${this.selector.text?.prefix}
|
||||
.suffix=${this.selector.text?.type === "password"
|
||||
? // reserve some space for the icon.
|
||||
html`<div style="width: 24px"></div>`
|
||||
: this.selector.text?.suffix}
|
||||
.required=${this.required}
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
></ha-textfield>
|
||||
${this.selector.text?.type === "password"
|
||||
? html`<ha-icon-button
|
||||
.label=${this.hass?.localize(
|
||||
this._unmaskedPassword
|
||||
? "ui.components.selectors.text.hide_password"
|
||||
: "ui.components.selectors.text.show_password"
|
||||
) || (this._unmaskedPassword ? "Hide password" : "Show password")}
|
||||
@click=${this._toggleUnmaskedPassword}
|
||||
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
|
||||
></ha-icon-button>`
|
||||
: ""}`;
|
||||
return html`<ha-input
|
||||
.name=${this.name}
|
||||
.value=${this.value || ""}
|
||||
.placeholder=${this.placeholder || ""}
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.type=${this.selector.text?.type}
|
||||
@input=${this._handleChange}
|
||||
@change=${this._handleChange}
|
||||
.label=${this.label || ""}
|
||||
.required=${this.required}
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
.passwordToggle=${this.selector.text?.type === "password"}
|
||||
>
|
||||
${this.selector.text?.prefix
|
||||
? html`<span slot="start">${this.selector.text.prefix}</span>`
|
||||
: nothing}
|
||||
${this.selector.text?.suffix
|
||||
? html`<span slot="end">${this.selector.text.suffix}</span>`
|
||||
: nothing}
|
||||
</ha-input>`;
|
||||
}
|
||||
|
||||
private _toggleUnmaskedPassword(): void {
|
||||
this._unmaskedPassword = !this._unmaskedPassword;
|
||||
}
|
||||
|
||||
private _handleChange(ev) {
|
||||
private _handleChange(ev: ValueChangedEvent<string> | InputEvent) {
|
||||
ev.stopPropagation();
|
||||
let value = ev.detail?.value ?? ev.target.value;
|
||||
let value: string | undefined =
|
||||
(ev as ValueChangedEvent<string>).detail?.value ??
|
||||
(ev.target as HTMLInputElement).value;
|
||||
if (this.value === value) {
|
||||
return;
|
||||
}
|
||||
@@ -139,20 +123,9 @@ export class HaTextSelector extends LitElement {
|
||||
position: relative;
|
||||
}
|
||||
ha-textarea,
|
||||
ha-textfield {
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export class HaSelectorUiColor extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-color-picker
|
||||
@@ -24,9 +26,11 @@ export class HaSelectorUiColor extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.includeNone=${this.selector.ui_color?.include_none}
|
||||
.includeState=${this.selector.ui_color?.include_state}
|
||||
.defaultColor=${this.selector.ui_color?.default_color}
|
||||
.extraOptions=${this.selector.ui_color?.extra_options}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-color-picker>
|
||||
`;
|
||||
|
||||
@@ -39,6 +39,7 @@ const LOAD_ELEMENTS = {
|
||||
language: () => import("./ha-selector-language"),
|
||||
navigation: () => import("./ha-selector-navigation"),
|
||||
number: () => import("./ha-selector-number"),
|
||||
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
|
||||
object: () => import("./ha-selector-object"),
|
||||
qr_code: () => import("./ha-selector-qr-code"),
|
||||
select: () => import("./ha-selector-select"),
|
||||
|
||||
@@ -14,6 +14,8 @@ export class HaSettingsRow extends LitElement {
|
||||
@property({ type: Boolean, attribute: "wrap-heading", reflect: true })
|
||||
public wrapHeading = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public empty = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="prefix-wrap">
|
||||
@@ -27,25 +29,30 @@ export class HaSettingsRow extends LitElement {
|
||||
<div class="secondary"><slot name="description"></slot></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content"><slot></slot></div>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
padding: 0 var(--ha-space-4);
|
||||
align-content: normal;
|
||||
align-self: auto;
|
||||
align-items: center;
|
||||
}
|
||||
.body {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
padding-top: var(--settings-row-body-padding-top, var(--ha-space-2));
|
||||
padding-bottom: var(
|
||||
--settings-row-body-padding-bottom,
|
||||
var(--ha-space-2)
|
||||
);
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
padding-right: var(--ha-space-4);
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
overflow: hidden;
|
||||
display: var(--layout-vertical_-_display, flex);
|
||||
flex-direction: var(--layout-vertical_-_flex-direction, column);
|
||||
@@ -63,7 +70,7 @@ export class HaSettingsRow extends LitElement {
|
||||
}
|
||||
.body > .secondary {
|
||||
display: block;
|
||||
padding-top: 4px;
|
||||
padding-top: var(--ha-space-1);
|
||||
font-family: var(
|
||||
--mdc-typography-body2-font-family,
|
||||
var(--mdc-typography-font-family, var(--ha-font-family-body))
|
||||
@@ -90,7 +97,10 @@ export class HaSettingsRow extends LitElement {
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 16px 0;
|
||||
padding: var(--settings-row-content-padding-block, var(--ha-space-4)) 0;
|
||||
}
|
||||
:host([empty]) .content {
|
||||
display: none;
|
||||
}
|
||||
.content ::slotted(*) {
|
||||
width: var(--settings-row-content-width);
|
||||
@@ -99,16 +109,16 @@ export class HaSettingsRow extends LitElement {
|
||||
align-items: normal;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
::slotted(ha-switch) {
|
||||
padding: 16px 0;
|
||||
padding: var(--settings-row-switch-padding-block, var(--ha-space-4)) 0;
|
||||
}
|
||||
.secondary {
|
||||
white-space: normal;
|
||||
}
|
||||
.prefix-wrap {
|
||||
flex: 1;
|
||||
flex: var(--settings-row-prefix-flex, 1);
|
||||
display: var(--settings-row-prefix-display);
|
||||
}
|
||||
:host([narrow]) .prefix-wrap {
|
||||
|
||||
@@ -20,6 +20,9 @@ export class HaTextArea extends TextAreaBase {
|
||||
textfieldStyles,
|
||||
textareaStyles,
|
||||
css`
|
||||
:host {
|
||||
--mdc-text-field-fill-color: var(--ha-color-form-background);
|
||||
}
|
||||
:host([autogrow]) .mdc-text-field {
|
||||
position: relative;
|
||||
min-height: 74px;
|
||||
|
||||
@@ -26,6 +26,9 @@ export class HaTimeInput extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
|
||||
|
||||
@property({ attribute: "placeholder-labels", type: Boolean })
|
||||
public placeholderLabels = false;
|
||||
|
||||
@query("ha-base-time-input") private _input?: HaBaseTimeInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
@@ -67,6 +70,7 @@ export class HaTimeInput extends LitElement {
|
||||
.required=${this.required}
|
||||
.clearable=${this.clearable && this.value !== undefined}
|
||||
.helper=${this.helper}
|
||||
.placeholderLabels=${this.placeholderLabels}
|
||||
day-label="dd"
|
||||
hour-label="hh"
|
||||
min-label="mm"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import "@home-assistant/webawesome/dist/components/popup/popup";
|
||||
import type WaPopup from "@home-assistant/webawesome/dist/components/popup/popup";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { nextRender } from "../common/util/render-status";
|
||||
|
||||
export type ToastCloseReason =
|
||||
| "dismiss"
|
||||
@@ -19,23 +22,100 @@ export class HaToast extends LitElement {
|
||||
|
||||
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
|
||||
|
||||
@query("wa-popover")
|
||||
private _popover?: WaPopover;
|
||||
@query("wa-popup")
|
||||
private _popup?: WaPopup;
|
||||
|
||||
@query(".toast")
|
||||
private _toast?: HTMLDivElement;
|
||||
|
||||
@state() private _active = false;
|
||||
|
||||
@state() private _visible = false;
|
||||
|
||||
private _dismissTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private _closeReason: ToastCloseReason = "programmatic";
|
||||
|
||||
private _transitionId = 0;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
clearTimeout(this._dismissTimer);
|
||||
this._transitionId += 1;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public async show(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._popover?.show();
|
||||
|
||||
clearTimeout(this._dismissTimer);
|
||||
|
||||
if (this._active && this._visible) {
|
||||
this._popup?.reposition();
|
||||
this._setDismissTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const transitionId = ++this._transitionId;
|
||||
|
||||
this._active = true;
|
||||
await this.updateComplete;
|
||||
|
||||
if (transitionId !== this._transitionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._popup?.reposition();
|
||||
await nextRender();
|
||||
|
||||
if (transitionId !== this._transitionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._visible = true;
|
||||
await this.updateComplete;
|
||||
await this._waitForTransitionEnd();
|
||||
|
||||
if (transitionId !== this._transitionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setDismissTimer();
|
||||
}
|
||||
|
||||
public async hide(reason: ToastCloseReason = "programmatic"): Promise<void> {
|
||||
clearTimeout(this._dismissTimer);
|
||||
this._closeReason = reason;
|
||||
|
||||
if (!this._active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transitionId = ++this._transitionId;
|
||||
const wasVisible = this._visible;
|
||||
|
||||
this._visible = false;
|
||||
await this.updateComplete;
|
||||
|
||||
if (wasVisible) {
|
||||
await this._waitForTransitionEnd();
|
||||
}
|
||||
|
||||
if (transitionId !== this._transitionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._active = false;
|
||||
await this.updateComplete;
|
||||
|
||||
fireEvent(this, "toast-closed", {
|
||||
reason: this._closeReason,
|
||||
});
|
||||
this._closeReason = "programmatic";
|
||||
}
|
||||
|
||||
public close(reason: ToastCloseReason = "programmatic"): void {
|
||||
this.hide(reason);
|
||||
}
|
||||
|
||||
private _setDismissTimer() {
|
||||
if (this.timeoutMs > 0) {
|
||||
this._dismissTimer = setTimeout(() => {
|
||||
this.hide("timeout");
|
||||
@@ -43,53 +123,53 @@ export class HaToast extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public async hide(reason: ToastCloseReason = "programmatic"): Promise<void> {
|
||||
clearTimeout(this._dismissTimer);
|
||||
this._closeReason = reason;
|
||||
await this._popover?.hide();
|
||||
}
|
||||
private async _waitForTransitionEnd(): Promise<void> {
|
||||
const toastEl = this._toast;
|
||||
if (!toastEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
public close(reason: ToastCloseReason = "programmatic"): void {
|
||||
this.hide(reason);
|
||||
}
|
||||
const animations = toastEl.getAnimations({ subtree: true });
|
||||
if (animations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
private _handleAfterHide() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<ToastClosedEventDetail>("toast-closed", {
|
||||
detail: { reason: this._closeReason },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
this._closeReason = "programmatic";
|
||||
await Promise.allSettled(animations.map((animation) => animation.finished));
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div id="toast-anchor" aria-hidden="true"></div>
|
||||
<wa-popover
|
||||
for="toast-anchor"
|
||||
<wa-popup
|
||||
placement="top"
|
||||
distance="16"
|
||||
.active=${this._active}
|
||||
.distance=${16}
|
||||
skidding="0"
|
||||
without-arrow
|
||||
@wa-after-hide=${this._handleAfterHide}
|
||||
flip
|
||||
shift
|
||||
>
|
||||
<div class="toast" role="status" aria-live="polite">
|
||||
<div id="toast-anchor" slot="anchor" aria-hidden="true"></div>
|
||||
<div
|
||||
class=${classMap({
|
||||
toast: true,
|
||||
visible: this._visible,
|
||||
})}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="message">${this.labelText}</span>
|
||||
<div class="actions">
|
||||
<slot name="action"></slot>
|
||||
<slot name="dismiss"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</wa-popover>
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
#toast-anchor {
|
||||
position: fixed;
|
||||
bottom: calc(8px + var(--safe-area-inset-bottom));
|
||||
bottom: calc(var(--ha-space-2) + var(--safe-area-inset-bottom));
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
@@ -98,22 +178,11 @@ export class HaToast extends LitElement {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--arrow-size: 0;
|
||||
--max-width: min(
|
||||
650px,
|
||||
calc(
|
||||
100vw -
|
||||
16px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
|
||||
)
|
||||
);
|
||||
--show-duration: var(--ha-animation-duration-fast, 150ms);
|
||||
--hide-duration: var(--ha-animation-duration-fast, 150ms);
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
wa-popup::part(popup) {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
box-shadow: var(--wa-shadow-l);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@@ -121,8 +190,9 @@ export class HaToast extends LitElement {
|
||||
min-width: min(
|
||||
350px,
|
||||
calc(
|
||||
100vw -
|
||||
16px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
|
||||
100vw - var(--ha-space-4) - var(--safe-area-inset-left) - var(
|
||||
--safe-area-inset-right
|
||||
)
|
||||
)
|
||||
);
|
||||
max-width: 650px;
|
||||
@@ -131,8 +201,19 @@ export class HaToast extends LitElement {
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-2) var(--ha-space-3);
|
||||
color: var(--inverse-primary-text-color);
|
||||
background-color: var(--inverse-surface-color);
|
||||
color: var(--ha-color-on-neutral-loud);
|
||||
background-color: var(--ha-color-neutral-10);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
opacity: 0;
|
||||
transform: translateY(var(--ha-space-2));
|
||||
transition:
|
||||
opacity var(--ha-animation-duration-fast, 150ms) ease,
|
||||
transform var(--ha-animation-duration-fast, 150ms) ease;
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.message {
|
||||
@@ -144,23 +225,27 @@ export class HaToast extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
color: var(--ha-color-on-neutral-loud);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
wa-popup::part(popup) {
|
||||
border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right)
|
||||
);
|
||||
border-radius: 0;
|
||||
border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
"toast-closed": CustomEvent<ToastClosedEventDetail>;
|
||||
interface HASSDomEvents {
|
||||
"toast-closed": ToastClosedEventDetail;
|
||||
}
|
||||
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
155
src/components/input/ha-input-copy.ts
Normal file
155
src/components/input/ha-input-copy.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { copyToClipboard } from "../../common/util/copy-clipboard";
|
||||
import { localizeContext } from "../../data/context";
|
||||
import { showToast } from "../../util/toast";
|
||||
import "../ha-button";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-svg-icon";
|
||||
import "./ha-input";
|
||||
import type { HaInput, InputType } from "./ha-input";
|
||||
|
||||
/**
|
||||
* Home Assistant input with copy button
|
||||
*
|
||||
* @element ha-input-copy
|
||||
* @extends {LitElement}
|
||||
*
|
||||
* @summary
|
||||
* A read-only input component with a copy-to-clipboard button.
|
||||
* Supports optional value masking with a toggle to reveal the hidden value.
|
||||
*
|
||||
* @attr {string} value - The value to display and copy.
|
||||
* @attr {string} masked-value - An alternative masked display value (for example, "••••••").
|
||||
* @attr {string} label - Label for the copy button. Defaults to the localized "Copy" text.
|
||||
* @attr {boolean} readonly - Makes the inner input readonly.
|
||||
* @attr {boolean} disabled - Disables the inner input.
|
||||
* @attr {boolean} masked-toggle - Shows a toggle button to reveal/hide the masked value.
|
||||
* @attr {("text"|"password"|"email"|"number"|"tel"|"url"|"search"|"date"|"datetime-local"|"time"|"color")} type - Sets the input type.
|
||||
* @attr {string} placeholder - Placeholder text for the input.
|
||||
* @attr {string} validation-message - Custom validation message.
|
||||
* @attr {boolean} auto-validate - Validates the input on blur.
|
||||
*/
|
||||
@customElement("ha-input-copy")
|
||||
export class HaInputCopy extends LitElement {
|
||||
@property({ attribute: "value" }) public value!: string;
|
||||
|
||||
@property({ attribute: "masked-value" }) public maskedValue?: string;
|
||||
|
||||
@property({ attribute: "label" }) public label?: string;
|
||||
|
||||
@property({ type: Boolean }) public readonly = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "masked-toggle" }) public maskedToggle =
|
||||
false;
|
||||
|
||||
@property() public type: InputType = "text";
|
||||
|
||||
@property()
|
||||
public placeholder = "";
|
||||
|
||||
@property({ attribute: "validation-message" })
|
||||
public validationMessage?: string;
|
||||
|
||||
@property({ type: Boolean, attribute: "auto-validate" }) public autoValidate =
|
||||
false;
|
||||
|
||||
@state()
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
private localize!: ContextType<typeof localizeContext>;
|
||||
|
||||
@state() private _showMasked = true;
|
||||
|
||||
@query("ha-input", true) private _inputElement?: HaInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._inputElement?.reportValidity() ?? false;
|
||||
}
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="textfield-container">
|
||||
<ha-input
|
||||
.type=${this.type}
|
||||
.value=${this._showMasked && this.maskedValue
|
||||
? this.maskedValue
|
||||
: this.value}
|
||||
.readonly=${this.readonly}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._focusInput}
|
||||
.placeholder=${this.placeholder}
|
||||
.autoValidate=${this.autoValidate}
|
||||
.validationMessage=${this.validationMessage}
|
||||
>
|
||||
${this.maskedToggle && this.maskedValue
|
||||
? html`<ha-icon-button
|
||||
slot="end"
|
||||
class="toggle-unmasked"
|
||||
.label=${this.localize(
|
||||
`ui.common.${this._showMasked ? "show" : "hide"}`
|
||||
)}
|
||||
@click=${this._toggleMasked}
|
||||
.path=${this._showMasked ? mdiEye : mdiEyeOff}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
</ha-input>
|
||||
</div>
|
||||
<ha-button @click=${this._copy} appearance="plain" size="small">
|
||||
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
|
||||
${this.label || this.localize("ui.common.copy")}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _focusInput(ev: Event) {
|
||||
const inputElement = ev.currentTarget as HaInput;
|
||||
inputElement.select();
|
||||
}
|
||||
|
||||
private _toggleMasked(): void {
|
||||
this._showMasked = !this._showMasked;
|
||||
}
|
||||
|
||||
private async _copy(): Promise<void> {
|
||||
await copyToClipboard(this.value);
|
||||
showToast(this, {
|
||||
message: this.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;
|
||||
}
|
||||
|
||||
.toggle-unmasked {
|
||||
--ha-icon-button-size: 40px;
|
||||
--mdc-icon-size: 20px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-button {
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-input-copy": HaInputCopy;
|
||||
}
|
||||
}
|
||||
238
src/components/input/ha-input-multi.ts
Normal file
238
src/components/input/ha-input-multi.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { localizeContext } from "../../data/context";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import "../ha-button";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-sortable";
|
||||
import "./ha-input";
|
||||
import type { HaInput, InputType } from "./ha-input";
|
||||
|
||||
/**
|
||||
* Home Assistant multi-value input component
|
||||
*
|
||||
* @element ha-input-multi
|
||||
* @extends {LitElement}
|
||||
*
|
||||
* @summary
|
||||
* A dynamic list of text inputs that allows adding, removing, and optionally reordering values.
|
||||
* Useful for managing arrays of strings such as URLs, tags, or other repeated text values.
|
||||
*
|
||||
* @attr {boolean} disabled - Disables all inputs and buttons.
|
||||
* @attr {boolean} sortable - Enables drag-and-drop reordering of items.
|
||||
* @attr {boolean} item-index - Appends a 1-based index number to each item's label.
|
||||
* @attr {boolean} update-on-blur - Fires value-changed on blur instead of on input.
|
||||
*
|
||||
* @fires value-changed - Fired when the list of values changes. `event.detail.value` contains the new string array.
|
||||
*/
|
||||
@customElement("ha-input-multi")
|
||||
class HaInputMulti extends LitElement {
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "input-type" }) public inputType?: InputType;
|
||||
|
||||
@property({ attribute: "input-suffix" }) public inputSuffix?: string;
|
||||
|
||||
@property({ attribute: "input-prefix" }) public inputPrefix?: string;
|
||||
|
||||
@property() public autocomplete?: string;
|
||||
|
||||
@property({ attribute: "add-label" }) public addLabel?: string;
|
||||
|
||||
@property({ attribute: "remove-label" }) public removeLabel?: string;
|
||||
|
||||
@property({ attribute: "item-index", type: Boolean })
|
||||
public itemIndex = false;
|
||||
|
||||
@property({ type: Number }) public max?: number;
|
||||
|
||||
@property({ type: Boolean }) public sortable = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "update-on-blur" })
|
||||
public updateOnBlur = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
private localize?: ContextType<typeof localizeContext>;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
handle-selector=".handle"
|
||||
draggable-selector=".row"
|
||||
.disabled=${!this.sortable || this.disabled}
|
||||
@item-moved=${this._itemMoved}
|
||||
>
|
||||
<div class="items">
|
||||
${repeat(
|
||||
this._items,
|
||||
(item, index) => `${item}-${index}`,
|
||||
(item, index) => {
|
||||
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
|
||||
return html`
|
||||
<div class="layout horizontal center-center row">
|
||||
<ha-input
|
||||
.type=${this.inputType}
|
||||
.autocomplete=${this.autocomplete}
|
||||
.disabled=${this.disabled}
|
||||
dialogInitialFocus=${index}
|
||||
.index=${index}
|
||||
class="flex-auto"
|
||||
.label=${`${this.label ? `${this.label}${indexSuffix}` : ""}`}
|
||||
.value=${item}
|
||||
?data-last=${index === this._items.length - 1}
|
||||
@input=${this._editItem}
|
||||
@change=${this._editItem}
|
||||
@keydown=${this._keyDown}
|
||||
>
|
||||
${this.inputPrefix
|
||||
? html`<span slot="start">${this.inputPrefix}</span>`
|
||||
: nothing}
|
||||
${this.inputSuffix
|
||||
? html`<span slot="end">${this.inputSuffix}</span>`
|
||||
: nothing}
|
||||
</ha-input>
|
||||
<ha-icon-button
|
||||
.disabled=${this.disabled}
|
||||
.index=${index}
|
||||
slot="navigationIcon"
|
||||
.label=${this.removeLabel ??
|
||||
this.localize?.("ui.common.remove") ??
|
||||
"Remove"}
|
||||
@click=${this._removeItem}
|
||||
.path=${mdiDeleteOutline}
|
||||
></ha-icon-button>
|
||||
${this.sortable
|
||||
? html`<ha-svg-icon
|
||||
class="handle"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<div class="layout horizontal">
|
||||
<ha-button
|
||||
size="small"
|
||||
appearance="filled"
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled ||
|
||||
(this.max != null && this._items.length >= this.max)}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.addLabel ??
|
||||
(this.label
|
||||
? this.localize?.("ui.components.multi-textfield.add_item", {
|
||||
item: this.label,
|
||||
})
|
||||
: this.localize?.("ui.common.add")) ??
|
||||
"Add"}
|
||||
</ha-button>
|
||||
</div>
|
||||
${this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private get _items() {
|
||||
return this.value ?? [];
|
||||
}
|
||||
|
||||
private async _addItem() {
|
||||
if (this.max != null && this._items.length >= this.max) {
|
||||
return;
|
||||
}
|
||||
const items = [...this._items, ""];
|
||||
this._fireChanged(items);
|
||||
await this.updateComplete;
|
||||
const field = this.shadowRoot?.querySelector(`ha-input[data-last]`) as
|
||||
| HaInput
|
||||
| undefined;
|
||||
field?.focus();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
if (this.updateOnBlur && ev.type === "input") {
|
||||
return;
|
||||
}
|
||||
if (!this.updateOnBlur && ev.type === "change") {
|
||||
return;
|
||||
}
|
||||
const index = (ev.target as any).index;
|
||||
const items = [...this._items];
|
||||
items[index] = (ev.target as any).value;
|
||||
this._fireChanged(items);
|
||||
}
|
||||
|
||||
private async _keyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter") {
|
||||
ev.stopPropagation();
|
||||
this._addItem();
|
||||
}
|
||||
}
|
||||
|
||||
private _itemMoved(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const items = [...this._items];
|
||||
const [moved] = items.splice(oldIndex, 1);
|
||||
items.splice(newIndex, 0, moved);
|
||||
this._fireChanged(items);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
const index = (ev.target as any).index;
|
||||
const items = [...this._items];
|
||||
items.splice(index, 1);
|
||||
this._fireChanged(items);
|
||||
}
|
||||
|
||||
private _fireChanged(value) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.row {
|
||||
margin-bottom: 8px;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
ha-icon-button {
|
||||
display: block;
|
||||
}
|
||||
.handle {
|
||||
cursor: grab;
|
||||
padding: 8px;
|
||||
margin: -8px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-input-multi": HaInputMulti;
|
||||
}
|
||||
}
|
||||
51
src/components/input/ha-input-search.ts
Normal file
51
src/components/input/ha-input-search.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiMagnify } from "@mdi/js";
|
||||
import { html, type PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { localizeContext } from "../../data/context";
|
||||
import { HaInput } from "./ha-input";
|
||||
|
||||
/**
|
||||
* Home Assistant search input component
|
||||
*
|
||||
* @element ha-input-search
|
||||
* @extends {HaInput}
|
||||
*
|
||||
* @summary
|
||||
* A pre-configured search input that extends `ha-input` with a magnify icon, clear button,
|
||||
* and a localized "Search" placeholder. Autocomplete is disabled by default.
|
||||
*/
|
||||
@customElement("ha-input-search")
|
||||
export class HaInputSearch extends HaInput {
|
||||
@state()
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
private localize!: ContextType<typeof localizeContext>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.withClear = true;
|
||||
this.autocomplete = this.autocomplete || "off";
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (
|
||||
!this.label &&
|
||||
!this.placeholder &&
|
||||
(!this.hasUpdated || changedProps.has("localize"))
|
||||
) {
|
||||
this.placeholder = this.localize("ui.common.search");
|
||||
}
|
||||
}
|
||||
|
||||
protected renderStartDefault() {
|
||||
return html`<ha-svg-icon slot="start" .path=${mdiMagnify}></ha-svg-icon>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-input-search": HaInputSearch;
|
||||
}
|
||||
}
|
||||
@@ -3,37 +3,89 @@ import "@home-assistant/webawesome/dist/components/input/input";
|
||||
import type WaInput from "@home-assistant/webawesome/dist/components/input/input";
|
||||
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
|
||||
import { mdiClose, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
|
||||
import {
|
||||
LitElement,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
css,
|
||||
html,
|
||||
nothing,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tooltip";
|
||||
|
||||
export type InputType =
|
||||
| "date"
|
||||
| "datetime-local"
|
||||
| "email"
|
||||
| "number"
|
||||
| "password"
|
||||
| "search"
|
||||
| "tel"
|
||||
| "text"
|
||||
| "time"
|
||||
| "color"
|
||||
| "url";
|
||||
|
||||
/**
|
||||
* Home Assistant input component
|
||||
*
|
||||
* @element ha-input
|
||||
* @extends {LitElement}
|
||||
*
|
||||
* @summary
|
||||
* 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.
|
||||
*
|
||||
* @slot start - Content placed before the input (usually for icons or prefixes).
|
||||
* @slot end - Content placed after the input (usually for icons or suffixes).
|
||||
* @slot label - Custom label content. Overrides the `label` property.
|
||||
* @slot hint - Custom hint content. Overrides the `hint` property.
|
||||
* @slot clear-icon - Custom clear icon. Defaults to a close icon button.
|
||||
* @slot show-password-icon - Custom show password icon. Defaults to an eye icon button.
|
||||
* @slot hide-password-icon - Custom hide password icon. Defaults to an eye-off icon button.
|
||||
*
|
||||
* @csspart wa-base - The underlying wa-input base wrapper.
|
||||
* @csspart wa-hint - The underlying wa-input hint container.
|
||||
* @csspart wa-input - The underlying wa-input input element.
|
||||
*
|
||||
* @cssprop --ha-input-padding-top - Padding above the input.
|
||||
* @cssprop --ha-input-padding-bottom - Padding below the input. Defaults to `var(--ha-space-2)`.
|
||||
* @cssprop --ha-input-text-align - Text alignment of the input. Defaults to `start`.
|
||||
* @cssprop --ha-input-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
*
|
||||
* @attr {("material"|"outlined")} appearance - Sets the input appearance style. "material" is the default filled style, "outlined" uses a bordered style.
|
||||
* @attr {("date"|"datetime-local"|"email"|"number"|"password"|"search"|"tel"|"text"|"time"|"color"|"url")} type - Sets the input type.
|
||||
* @attr {string} label - The input's label text.
|
||||
* @attr {string} hint - The input's hint/helper text.
|
||||
* @attr {string} placeholder - Placeholder text shown when the input is empty.
|
||||
* @attr {boolean} with-clear - Adds a clear button when the input is not empty.
|
||||
* @attr {boolean} readonly - Makes the input readonly.
|
||||
* @attr {boolean} disabled - Disables the input and prevents user interaction.
|
||||
* @attr {boolean} required - Makes the input a required field.
|
||||
* @attr {boolean} password-toggle - Adds a button to toggle the password visibility.
|
||||
* @attr {boolean} without-spin-buttons - Hides the browser's built-in spin buttons for number inputs.
|
||||
* @attr {boolean} auto-validate - Validates the input on blur instead of on form submit.
|
||||
* @attr {boolean} invalid - Marks the input as invalid.
|
||||
* @attr {boolean} inset-label - Uses an inset label style where the label stays inside the input.
|
||||
* @attr {string} validation-message - Custom validation message shown when the input is invalid.
|
||||
*/
|
||||
@customElement("ha-input")
|
||||
export class HaInput extends LitElement {
|
||||
@property({ reflect: true }) appearance: "material" | "outlined" = "material";
|
||||
|
||||
@property({ reflect: true })
|
||||
public type:
|
||||
| "date"
|
||||
| "datetime-local"
|
||||
| "email"
|
||||
| "number"
|
||||
| "password"
|
||||
| "search"
|
||||
| "tel"
|
||||
| "text"
|
||||
| "time"
|
||||
| "url" = "text";
|
||||
public type: InputType = "text";
|
||||
|
||||
@property()
|
||||
public value?: string;
|
||||
|
||||
/** Draws a pill-style input with rounded edges. */
|
||||
@property({ type: Boolean })
|
||||
public pill = false;
|
||||
|
||||
/** The input's label. */
|
||||
@property()
|
||||
public label = "";
|
||||
@@ -271,7 +323,7 @@ export class HaInput extends LitElement {
|
||||
.type=${this.type}
|
||||
.value=${this.value ?? null}
|
||||
.withClear=${this.withClear}
|
||||
.placeholder=${this.placeholder && this.label ? this.placeholder : ""}
|
||||
.placeholder=${this.placeholder}
|
||||
.readonly=${this.readonly}
|
||||
.passwordToggle=${this.passwordToggle}
|
||||
.passwordVisible=${this.passwordVisible}
|
||||
@@ -294,7 +346,16 @@ export class HaInput extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
class=${classMap({
|
||||
invalid: this.invalid || this._invalid,
|
||||
"label-raised": this.value || this.placeholder,
|
||||
"label-raised":
|
||||
(this.value !== undefined && this.value !== "") ||
|
||||
(this.label && this.placeholder),
|
||||
"no-label": !this.label,
|
||||
"hint-hidden":
|
||||
!this.hint &&
|
||||
!hasHintSlot &&
|
||||
!this.required &&
|
||||
!this._invalid &&
|
||||
!this.invalid,
|
||||
})}
|
||||
@input=${this._handleInput}
|
||||
@change=${this._handleChange}
|
||||
@@ -304,19 +365,13 @@ export class HaInput extends LitElement {
|
||||
>
|
||||
${this.label || hasLabelSlot
|
||||
? html`<slot name="label" slot="label"
|
||||
>${this._renderLabel(
|
||||
this.label,
|
||||
this.placeholder,
|
||||
this.required
|
||||
)}</slot
|
||||
>${this._renderLabel(this.label, this.required)}</slot
|
||||
>`
|
||||
: nothing}
|
||||
<slot
|
||||
name="start"
|
||||
slot="start"
|
||||
@slotchange=${this._syncStartSlotWidth}
|
||||
></slot>
|
||||
<slot name="end" slot="end"></slot>
|
||||
<slot name="start" slot="start" @slotchange=${this._syncStartSlotWidth}>
|
||||
${this.renderStartDefault()}
|
||||
</slot>
|
||||
<slot name="end" slot="end"> ${this.renderEndDefault()} </slot>
|
||||
<slot name="clear-icon" slot="clear-icon">
|
||||
<ha-icon-button .path=${mdiClose}></ha-icon-button>
|
||||
</slot>
|
||||
@@ -334,7 +389,9 @@ export class HaInput extends LitElement {
|
||||
</slot>
|
||||
<div
|
||||
slot="hint"
|
||||
class=${this.invalid || this._invalid ? "error" : ""}
|
||||
class=${classMap({
|
||||
error: this.invalid || this._invalid,
|
||||
})}
|
||||
role=${ifDefined(this.invalid || this._invalid ? "alert" : undefined)}
|
||||
aria-live="polite"
|
||||
>
|
||||
@@ -347,6 +404,14 @@ export class HaInput extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected renderStartDefault(): TemplateResult | typeof nothing {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
protected renderEndDefault(): TemplateResult | typeof nothing {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _handleInput() {
|
||||
this.value = this._input?.value ?? undefined;
|
||||
if (this._invalid && this._input?.checkValidity()) {
|
||||
@@ -388,46 +453,44 @@ export class HaInput extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private _renderLabel = memoizeOne(
|
||||
(label: string, placeholder: string, required: boolean) => {
|
||||
// fallback to placeholder if no label is provided
|
||||
const text = label || placeholder;
|
||||
if (!required) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let marker = getComputedStyle(this).getPropertyValue(
|
||||
"--ha-input-required-marker"
|
||||
);
|
||||
|
||||
if (!marker) {
|
||||
marker = "*";
|
||||
}
|
||||
|
||||
if (marker.startsWith('"') && marker.endsWith('"')) {
|
||||
marker = marker.slice(1, -1);
|
||||
}
|
||||
|
||||
if (!marker) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text}${marker}`;
|
||||
private _renderLabel = memoizeOne((label: string, required: boolean) => {
|
||||
if (!required) {
|
||||
return label;
|
||||
}
|
||||
);
|
||||
|
||||
let marker = getComputedStyle(this).getPropertyValue(
|
||||
"--ha-input-required-marker"
|
||||
);
|
||||
|
||||
if (!marker) {
|
||||
marker = "*";
|
||||
}
|
||||
|
||||
if (marker.startsWith('"') && marker.endsWith('"')) {
|
||||
marker = marker.slice(1, -1);
|
||||
}
|
||||
|
||||
if (!marker) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `${label}${marker}`;
|
||||
});
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: var(--ha-input-padding-top, var(--ha-space-2));
|
||||
padding-top: var(--ha-input-padding-top);
|
||||
padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2));
|
||||
text-align: var(--ha-input-text-align, start);
|
||||
}
|
||||
:host([appearance="outlined"]) {
|
||||
padding-bottom: var(--ha-input-padding-bottom);
|
||||
}
|
||||
wa-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 76px;
|
||||
--wa-transition-fast: var(--wa-transition-normal);
|
||||
position: relative;
|
||||
}
|
||||
@@ -441,6 +504,7 @@ export class HaInput extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
padding-inline-start: calc(
|
||||
var(--start-slot-width, 0px) + var(--ha-space-4)
|
||||
);
|
||||
@@ -449,7 +513,7 @@ export class HaInput extends LitElement {
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
|
||||
:host(:focus-within) wa-input::part(label) {
|
||||
:host([appearance="material"]:focus-within) wa-input::part(label) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@@ -459,14 +523,15 @@ export class HaInput extends LitElement {
|
||||
}
|
||||
|
||||
wa-input.label-raised::part(label),
|
||||
:host(:focus-within) wa-input::part(label) {
|
||||
:host(:focus-within) wa-input::part(label),
|
||||
:host([type="date"]) wa-input::part(label) {
|
||||
padding-top: var(--ha-space-3);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
|
||||
wa-input::part(base) {
|
||||
height: 56px;
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
background-color: var(--ha-color-form-background);
|
||||
border-top-left-radius: var(--ha-border-radius-sm);
|
||||
border-top-right-radius: var(--ha-border-radius-sm);
|
||||
border-bottom-left-radius: var(--ha-border-radius-square);
|
||||
@@ -477,7 +542,19 @@ export class HaInput extends LitElement {
|
||||
transition: background-color var(--wa-transition-normal) ease-in-out;
|
||||
}
|
||||
|
||||
wa-input::part(base)::after {
|
||||
:host([appearance="outlined"]) wa-input.no-label::part(base) {
|
||||
height: 32px;
|
||||
padding: 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
:host([appearance="outlined"]) wa-input::part(base) {
|
||||
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
background-color: var(--card-background-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
transition: border-color var(--wa-transition-normal) ease-in-out;
|
||||
}
|
||||
|
||||
:host([appearance="material"]) ::part(base)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -490,13 +567,15 @@ export class HaInput extends LitElement {
|
||||
background-color var(--wa-transition-normal) ease-in-out;
|
||||
}
|
||||
|
||||
:host(:focus-within) wa-input::part(base)::after {
|
||||
:host([appearance="material"]:focus-within) wa-input::part(base)::after {
|
||||
height: 2px;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
:host(:focus-within) wa-input.invalid::part(base)::after,
|
||||
wa-input.invalid:not([disabled])::part(base)::after {
|
||||
:host([appearance="material"]:focus-within)
|
||||
wa-input.invalid::part(base)::after,
|
||||
:host([appearance="material"])
|
||||
wa-input.invalid:not([disabled])::part(base)::after {
|
||||
background-color: var(--ha-color-border-danger-normal);
|
||||
}
|
||||
|
||||
@@ -504,8 +583,19 @@ export class HaInput extends LitElement {
|
||||
padding-top: var(--ha-space-3);
|
||||
padding-inline-start: var(--input-padding-inline-start, 0);
|
||||
}
|
||||
|
||||
wa-input.no-label::part(input) {
|
||||
padding-top: 0;
|
||||
}
|
||||
:host([type="color"]) wa-input::part(input) {
|
||||
padding-top: var(--ha-space-6);
|
||||
cursor: pointer;
|
||||
}
|
||||
:host([type="color"]) wa-input.no-label::part(input) {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
:host([type="color"]) wa-input.no-label::part(base) {
|
||||
padding: 0;
|
||||
}
|
||||
wa-input::part(input)::placeholder {
|
||||
color: var(--ha-color-neutral-60);
|
||||
@@ -516,11 +606,18 @@ export class HaInput extends LitElement {
|
||||
}
|
||||
|
||||
wa-input::part(base):hover {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-hover);
|
||||
background-color: var(--ha-color-form-background-hover);
|
||||
}
|
||||
|
||||
:host([appearance="outlined"]) wa-input::part(base):hover {
|
||||
border-color: var(--ha-color-border-neutral-normal);
|
||||
}
|
||||
:host([appearance="outlined"]:focus-within) wa-input::part(base) {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
wa-input:disabled::part(base) {
|
||||
background-color: var(--ha-color-fill-disabled-quiet-resting);
|
||||
background-color: var(--ha-color-form-background-disabled);
|
||||
}
|
||||
|
||||
wa-input::part(hint) {
|
||||
@@ -533,6 +630,10 @@ export class HaInput extends LitElement {
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
|
||||
wa-input.hint-hidden::part(hint) {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
}
|
||||
@@ -540,6 +641,11 @@ export class HaInput extends LitElement {
|
||||
wa-input::part(end) {
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
|
||||
:host([appearance="outlined"]) wa-input.no-label {
|
||||
--ha-icon-button-size: 24px;
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,10 +54,19 @@ export interface HaMapPaths {
|
||||
fullDatetime?: boolean;
|
||||
}
|
||||
|
||||
export const MAP_CARD_MARKER_LABEL_MODES = [
|
||||
"name",
|
||||
"state",
|
||||
"attribute",
|
||||
"icon",
|
||||
] as const;
|
||||
export type MapCardMarkerLabelMode =
|
||||
(typeof MAP_CARD_MARKER_LABEL_MODES)[number];
|
||||
|
||||
export interface HaMapEntity {
|
||||
entity_id: string;
|
||||
color: string;
|
||||
label_mode?: "name" | "state" | "attribute" | "icon";
|
||||
label_mode?: MapCardMarkerLabelMode;
|
||||
attribute?: string;
|
||||
unit?: string;
|
||||
name?: string;
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { mdiClose, mdiMagnify } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-outlined-text-field";
|
||||
import type { HaOutlinedTextField } from "./ha-outlined-text-field";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("search-input-outlined")
|
||||
class SearchInputOutlined extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public filter?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public suffix = false;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder?: string;
|
||||
|
||||
public focus() {
|
||||
this._input?.focus();
|
||||
}
|
||||
|
||||
@query("ha-outlined-text-field", true) private _input!: HaOutlinedTextField;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const placeholder =
|
||||
this.placeholder || this.hass.localize("ui.common.search");
|
||||
|
||||
return html`
|
||||
<ha-outlined-text-field
|
||||
.autofocus=${this.autofocus}
|
||||
.aria-label=${this.label || this.hass.localize("ui.common.search")}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.filter || ""}
|
||||
icon
|
||||
.iconTrailing=${this.filter || this.suffix}
|
||||
@input=${this._filterInputChanged}
|
||||
dense
|
||||
>
|
||||
<slot name="prefix" slot="leading-icon">
|
||||
<ha-svg-icon
|
||||
tabindex="-1"
|
||||
class="prefix"
|
||||
.path=${mdiMagnify}
|
||||
></ha-svg-icon>
|
||||
</slot>
|
||||
${this.filter
|
||||
? html`<ha-icon-button
|
||||
aria-label="Clear input"
|
||||
slot="trailing-icon"
|
||||
@click=${this._clearSearch}
|
||||
.path=${mdiClose}
|
||||
>
|
||||
</ha-icon-button>`
|
||||
: nothing}
|
||||
</ha-outlined-text-field>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _filterChanged(value: string) {
|
||||
fireEvent(this, "value-changed", { value: String(value) });
|
||||
}
|
||||
|
||||
private async _filterInputChanged(e) {
|
||||
this._filterChanged(e.target.value);
|
||||
}
|
||||
|
||||
private async _clearSearch() {
|
||||
this._filterChanged("");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
/* For iOS */
|
||||
z-index: 0;
|
||||
}
|
||||
ha-outlined-text-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
--ha-outlined-field-container-color: var(--card-background-color);
|
||||
}
|
||||
ha-svg-icon,
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 24px;
|
||||
height: var(--ha-icon-button-size);
|
||||
display: flex;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-svg-icon {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"search-input-outlined": SearchInputOutlined;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { mdiClose, mdiMagnify } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
|
||||
@customElement("search-input")
|
||||
class SearchInput extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public filter?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public suffix = false;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
public focus() {
|
||||
this._input?.focus();
|
||||
}
|
||||
|
||||
@query("ha-textfield", true) private _input!: HaTextField;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-textfield
|
||||
.autofocus=${this.autofocus}
|
||||
autocomplete="off"
|
||||
.label=${this.label || this.hass.localize("ui.common.search")}
|
||||
.value=${this.filter || ""}
|
||||
icon
|
||||
.iconTrailing=${this.filter || this.suffix}
|
||||
@input=${this._filterInputChanged}
|
||||
>
|
||||
<slot name="prefix" slot="leadingIcon">
|
||||
<ha-svg-icon
|
||||
tabindex="-1"
|
||||
class="prefix"
|
||||
.path=${mdiMagnify}
|
||||
></ha-svg-icon>
|
||||
</slot>
|
||||
<div class="trailing" slot="trailingIcon">
|
||||
${this.filter &&
|
||||
html`
|
||||
<ha-icon-button
|
||||
@click=${this._clearSearch}
|
||||
.label=${this.hass.localize("ui.common.clear")}
|
||||
.path=${mdiClose}
|
||||
class="clear-button"
|
||||
></ha-icon-button>
|
||||
`}
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
</ha-textfield>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _filterChanged(value: string) {
|
||||
fireEvent(this, "value-changed", { value: String(value) });
|
||||
}
|
||||
|
||||
private async _filterInputChanged(e) {
|
||||
this._filterChanged(e.target.value);
|
||||
}
|
||||
|
||||
private async _clearSearch() {
|
||||
this._filterChanged("");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
ha-svg-icon,
|
||||
ha-icon-button {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-svg-icon {
|
||||
outline: none;
|
||||
}
|
||||
.clear-button {
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
ha-textfield {
|
||||
display: inherit;
|
||||
}
|
||||
.trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"search-input": SearchInput;
|
||||
}
|
||||
}
|
||||
@@ -761,6 +761,7 @@ export class HatScriptGraph extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: fit-content;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
@@ -117,6 +117,7 @@ interface PipelineIntentStartEvent extends PipelineEventBase {
|
||||
export interface ConversationChatLogAssistantDelta {
|
||||
role: "assistant";
|
||||
content: string;
|
||||
thinking_content?: string;
|
||||
tool_calls: {
|
||||
id: string;
|
||||
tool_name: string;
|
||||
|
||||
@@ -3,27 +3,30 @@ import {
|
||||
addHours,
|
||||
addMilliseconds,
|
||||
addMonths,
|
||||
addYears,
|
||||
differenceInDays,
|
||||
differenceInMonths,
|
||||
endOfDay,
|
||||
startOfDay,
|
||||
isFirstDayOfMonth,
|
||||
isLastDayOfMonth,
|
||||
addYears,
|
||||
startOfDay,
|
||||
} from "date-fns";
|
||||
import type { Collection, HassEntity } from "home-assistant-js-websocket";
|
||||
import { getCollection } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
import {
|
||||
calcDate,
|
||||
calcDateProperty,
|
||||
calcDateDifferenceProperty,
|
||||
calcDateProperty,
|
||||
} from "../common/datetime/calc_date";
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
import { groupBy } from "../common/util/group-by";
|
||||
import { fileDownload } from "../util/file_download";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { fileDownload } from "../util/file_download";
|
||||
import type {
|
||||
Statistics,
|
||||
StatisticsMetaData,
|
||||
@@ -36,9 +39,6 @@ import {
|
||||
getStatisticMetadata,
|
||||
VOLUME_UNITS,
|
||||
} from "./recorder";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
|
||||
export const ENERGY_COLLECTION_KEY_PREFIX = "energy_";
|
||||
|
||||
@@ -841,7 +841,7 @@ export const getEnergyDataCollection = (
|
||||
const period =
|
||||
preferredPeriod === "today" && hour === "0" ? "yesterday" : preferredPeriod;
|
||||
|
||||
const [start, end] = calcDateRange(hass, period);
|
||||
const [start, end] = calcDateRange(hass.locale, hass.config, period);
|
||||
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
|
||||
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
|
||||
|
||||
@@ -1461,7 +1461,7 @@ export const calculateSolarConsumedGauge = (
|
||||
/** Exact number of liters in one US gallon */
|
||||
const LITERS_PER_GALLON = 3.785411784;
|
||||
|
||||
const FLOW_RATE_TO_LMIN: Record<string, number> = {
|
||||
export const FLOW_RATE_TO_LMIN: Record<string, number> = {
|
||||
"m³/h": 1000 / 60,
|
||||
"m³/min": 1000,
|
||||
"m³/s": 60000,
|
||||
@@ -1498,6 +1498,75 @@ export const getFlowRateFromState = (
|
||||
return value * factor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the total flow rate across all energy sources of a given type.
|
||||
* Used by gas and water total badges.
|
||||
*/
|
||||
export const computeTotalFlowRate = (
|
||||
sourceType: "gas" | "water",
|
||||
prefs: EnergyPreferences,
|
||||
states: HomeAssistant["states"],
|
||||
entities: Set<string>
|
||||
): { value: number; unit: string } => {
|
||||
entities.clear();
|
||||
|
||||
let targetUnit: string | undefined;
|
||||
let totalFlow = 0;
|
||||
|
||||
prefs.energy_sources.forEach((source) => {
|
||||
if (source.type !== sourceType || !source.stat_rate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entityId = source.stat_rate;
|
||||
entities.add(entityId);
|
||||
|
||||
const stateObj = states[entityId];
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rawValue = parseFloat(stateObj.state);
|
||||
if (isNaN(rawValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawValue < 0) {
|
||||
rawValue = 0;
|
||||
}
|
||||
|
||||
const entityUnit = stateObj.attributes.unit_of_measurement;
|
||||
if (!entityUnit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetUnit === undefined) {
|
||||
targetUnit = entityUnit;
|
||||
totalFlow += rawValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityUnit === targetUnit) {
|
||||
totalFlow += rawValue;
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFactor = FLOW_RATE_TO_LMIN[entityUnit];
|
||||
const targetFactor = FLOW_RATE_TO_LMIN[targetUnit];
|
||||
|
||||
if (sourceFactor !== undefined && targetFactor !== undefined) {
|
||||
totalFlow += (rawValue * sourceFactor) / targetFactor;
|
||||
} else {
|
||||
totalFlow += rawValue;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
value: Math.max(0, totalFlow),
|
||||
unit: targetUnit ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a flow rate value (in L/min) to a human-readable string using
|
||||
* the preferred unit system: metric → L/min, imperial → gal/min.
|
||||
|
||||
@@ -92,16 +92,24 @@ export interface LightEntityOptions {
|
||||
favorite_colors?: LightColor[];
|
||||
}
|
||||
|
||||
export interface ValveEntityOptions {
|
||||
favorite_positions?: number[];
|
||||
}
|
||||
|
||||
export type FavoriteOption =
|
||||
| "favorite_colors"
|
||||
| "favorite_positions"
|
||||
| "favorite_tilt_positions";
|
||||
|
||||
export type FavoritesDomain = "light" | "cover";
|
||||
export type FavoritesDomain = "light" | "cover" | "valve";
|
||||
|
||||
export type FavoriteOptionValue = LightColor[] | number[];
|
||||
|
||||
export const DOMAINS_WITH_FAVORITES: FavoritesDomain[] = ["light", "cover"];
|
||||
export const DOMAINS_WITH_FAVORITES: FavoritesDomain[] = [
|
||||
"light",
|
||||
"cover",
|
||||
"valve",
|
||||
];
|
||||
|
||||
export const isFavoritesDomain = (domain: string): domain is FavoritesDomain =>
|
||||
DOMAINS_WITH_FAVORITES.includes(domain as FavoritesDomain);
|
||||
@@ -162,6 +170,7 @@ export interface EntityRegistryOptions {
|
||||
weather?: WeatherEntityOptions;
|
||||
light?: LightEntityOptions;
|
||||
cover?: CoverEntityOptions;
|
||||
valve?: ValveEntityOptions;
|
||||
vacuum?: VacuumEntityOptions;
|
||||
switch_as_x?: SwitchAsXEntityOptions;
|
||||
conversation?: Record<string, unknown>;
|
||||
@@ -187,6 +196,7 @@ export interface EntityRegistryEntryUpdateParams {
|
||||
| WeatherEntityOptions
|
||||
| LightEntityOptions
|
||||
| CoverEntityOptions
|
||||
| ValveEntityOptions
|
||||
| VacuumEntityOptions;
|
||||
aliases?: (string | null)[];
|
||||
labels?: string[];
|
||||
|
||||
@@ -464,6 +464,15 @@ export const convertStatisticsToHistory = (
|
||||
return statisticsHistory;
|
||||
};
|
||||
|
||||
export const limitedHistoryFromStateObj = (
|
||||
state: HassEntity
|
||||
): EntityHistoryState[] => [
|
||||
{
|
||||
s: state.state,
|
||||
a: state.attributes,
|
||||
lu: new Date(state.last_updated).getTime() / 1000,
|
||||
},
|
||||
];
|
||||
export const computeHistory = (
|
||||
hass: HomeAssistant,
|
||||
stateHistory: HistoryStates,
|
||||
@@ -484,13 +493,9 @@ export const computeHistory = (
|
||||
if (entity in stateHistory) {
|
||||
localStateHistory[entity] = stateHistory[entity];
|
||||
} else if (hass.states[entity]) {
|
||||
localStateHistory[entity] = [
|
||||
{
|
||||
s: hass.states[entity].state,
|
||||
a: hass.states[entity].attributes,
|
||||
lu: new Date(hass.states[entity].last_updated).getTime() / 1000,
|
||||
},
|
||||
];
|
||||
localStateHistory[entity] = limitedHistoryFromStateObj(
|
||||
hass.states[entity]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
mdiHomeAutomation,
|
||||
mdiImage,
|
||||
mdiImageFilterFrames,
|
||||
mdiLedOn,
|
||||
mdiLightbulb,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMicrophoneMessage,
|
||||
@@ -89,6 +90,7 @@ export const FALLBACK_DOMAIN_ICONS = {
|
||||
homekit: mdiHomeAutomation,
|
||||
image_processing: mdiImageFilterFrames,
|
||||
image: mdiImage,
|
||||
infrared: mdiLedOn,
|
||||
input_boolean: mdiToggleSwitch,
|
||||
input_button: mdiButtonPointer,
|
||||
input_datetime: mdiCalendarClock,
|
||||
|
||||
@@ -2,11 +2,19 @@ import type { Condition } from "../../../panels/lovelace/common/validate-conditi
|
||||
import type { LovelaceCardConfig } from "./card";
|
||||
import type { LovelaceStrategyConfig } from "./strategy";
|
||||
|
||||
export const DEFAULT_SECTION_BACKGROUND_OPACITY = 50;
|
||||
|
||||
export interface LovelaceSectionBackgroundConfig {
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface LovelaceBaseSectionConfig {
|
||||
visibility?: Condition[];
|
||||
disabled?: boolean;
|
||||
column_span?: number;
|
||||
row_span?: number;
|
||||
background?: boolean | LovelaceSectionBackgroundConfig;
|
||||
/**
|
||||
* @deprecated Use heading card instead.
|
||||
*/
|
||||
@@ -26,6 +34,15 @@ export type LovelaceSectionRawConfig =
|
||||
| LovelaceSectionConfig
|
||||
| LovelaceStrategySectionConfig;
|
||||
|
||||
export function resolveSectionBackground(
|
||||
background: boolean | LovelaceSectionBackgroundConfig | undefined
|
||||
): LovelaceSectionBackgroundConfig | undefined {
|
||||
if (typeof background === "boolean") {
|
||||
return background ? {} : undefined;
|
||||
}
|
||||
return background;
|
||||
}
|
||||
|
||||
export function isStrategySection(
|
||||
section: LovelaceSectionRawConfig
|
||||
): section is LovelaceStrategySectionConfig {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const SCENE_IGNORED_DOMAINS = [
|
||||
"device_tracker",
|
||||
"event",
|
||||
"image_processing",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"persistent_notification",
|
||||
"person",
|
||||
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
} from "./entity/entity_registry";
|
||||
import type { EntitySources } from "./entity/entity_sources";
|
||||
|
||||
export type ThresholdMode = "crossed" | "changed" | "is";
|
||||
|
||||
export type Selector =
|
||||
| ActionSelector
|
||||
| AddonSelector
|
||||
@@ -56,6 +58,7 @@ export type Selector =
|
||||
| MediaSelector
|
||||
| NavigationSelector
|
||||
| NumberSelector
|
||||
| NumericThresholdSelector
|
||||
| ObjectSelector
|
||||
| AssistPipelineSelector
|
||||
| QRCodeSelector
|
||||
@@ -241,6 +244,7 @@ interface EntitySelectorFilter {
|
||||
domain?: string | readonly string[];
|
||||
device_class?: string | readonly string[];
|
||||
supported_features?: number | [number];
|
||||
unit_of_measurement?: string | readonly string[];
|
||||
}
|
||||
|
||||
export interface EntitySelector {
|
||||
@@ -362,6 +366,15 @@ export interface NumberSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface NumericThresholdSelector {
|
||||
numeric_threshold: {
|
||||
mode?: ThresholdMode;
|
||||
unit_of_measurement?: readonly string[];
|
||||
number?: NumberSelector["number"];
|
||||
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ObjectSelectorField {
|
||||
selector: Selector;
|
||||
label?: string;
|
||||
@@ -506,11 +519,19 @@ export interface UiActionSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UiColorExtraOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
display_color?: string;
|
||||
}
|
||||
|
||||
export interface UiColorSelector {
|
||||
ui_color: {
|
||||
default_color?: string;
|
||||
include_none?: boolean;
|
||||
include_state?: boolean;
|
||||
extra_options?: UiColorExtraOption[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -811,6 +832,7 @@ export const filterSelectorEntities = (
|
||||
domain: filterDomain,
|
||||
device_class: filterDeviceClass,
|
||||
supported_features: filterSupportedFeature,
|
||||
unit_of_measurement: filterUnitOfMeasurement,
|
||||
integration: filterIntegration,
|
||||
} = filterEntity;
|
||||
|
||||
@@ -846,6 +868,18 @@ export const filterSelectorEntities = (
|
||||
}
|
||||
}
|
||||
|
||||
if (filterUnitOfMeasurement) {
|
||||
const entityUnitOfMeasurement = entity.attributes.unit_of_measurement;
|
||||
if (
|
||||
!entityUnitOfMeasurement ||
|
||||
(Array.isArray(filterUnitOfMeasurement)
|
||||
? !filterUnitOfMeasurement.includes(entityUnitOfMeasurement)
|
||||
: entityUnitOfMeasurement !== filterUnitOfMeasurement)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filterIntegration &&
|
||||
entitySources?.[entity.entity_id]?.domain !== filterIntegration
|
||||
|
||||
@@ -47,13 +47,34 @@ export interface ExtractFromTargetResultReferenced {
|
||||
|
||||
export const extractFromTarget = async (
|
||||
hass: HomeAssistant,
|
||||
target: HassServiceTarget
|
||||
target: HassServiceTarget,
|
||||
expandGroup = false
|
||||
) =>
|
||||
hass.callWS<ExtractFromTargetResult>({
|
||||
type: "extract_from_target",
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
});
|
||||
|
||||
export const getResolvedTargetEntityCount = async (
|
||||
hass: HomeAssistant,
|
||||
target?: HassServiceTarget
|
||||
): Promise<number | undefined> => {
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return (await extractFromTarget(hass, target, true)).referenced_entities
|
||||
.length;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error resolving target entity count", err);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getTriggersForTarget = async (
|
||||
callWS: HomeAssistant["callWS"],
|
||||
target: HassServiceTarget,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
HassEntityBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { stateActive } from "../common/entity/state_active";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
|
||||
@@ -13,6 +14,41 @@ export const enum ValveEntityFeature {
|
||||
STOP = 8,
|
||||
}
|
||||
|
||||
export const DEFAULT_VALVE_FAVORITE_POSITIONS = [0, 25, 75, 100];
|
||||
|
||||
export const valveSupportsPosition = (stateObj: ValveEntity) =>
|
||||
supportsFeature(stateObj, ValveEntityFeature.SET_POSITION);
|
||||
|
||||
export const normalizeValveFavoritePositions = (
|
||||
positions?: number[]
|
||||
): number[] => {
|
||||
if (!positions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const unique = new Set<number>();
|
||||
const normalized: number[] = [];
|
||||
|
||||
for (const position of positions) {
|
||||
const value = Number(position);
|
||||
|
||||
if (isNaN(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(value)));
|
||||
|
||||
if (unique.has(clamped)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.add(clamped);
|
||||
normalized.push(clamped);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export function isFullyOpen(stateObj: ValveEntity) {
|
||||
if (
|
||||
stateObj.attributes.current_position !== undefined &&
|
||||
|
||||
@@ -16,6 +16,7 @@ import "../../../components/ha-humidifier-state";
|
||||
import "../../../components/ha-select";
|
||||
import "../../../components/ha-slider";
|
||||
import "../../../components/ha-time-input";
|
||||
import "../../../components/input/ha-input";
|
||||
import { isTiltOnly } from "../../../data/cover";
|
||||
import { isUnavailableState } from "../../../data/entity/entity";
|
||||
import type { ImageEntity } from "../../../data/image";
|
||||
@@ -72,7 +73,7 @@ class EntityPreviewRow extends LitElement {
|
||||
min-width: 45px;
|
||||
text-align: end;
|
||||
}
|
||||
ha-textfield {
|
||||
ha-input {
|
||||
text-align: end;
|
||||
direction: ltr !important;
|
||||
}
|
||||
@@ -273,18 +274,23 @@ class EntityPreviewRow extends LitElement {
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: html` <div class="numberflex numberstate">
|
||||
<ha-textfield
|
||||
autoValidate
|
||||
: html`<div class="numberflex numberstate">
|
||||
<ha-input
|
||||
auto-validate
|
||||
.disabled=${isUnavailableState(stateObj.state)}
|
||||
pattern="[0-9]+([\\.][0-9]+)?"
|
||||
.step=${Number(stateObj.attributes.step)}
|
||||
.min=${Number(stateObj.attributes.min)}
|
||||
.max=${Number(stateObj.attributes.max)}
|
||||
.value=${stateObj.state}
|
||||
.suffix=${stateObj.attributes.unit_of_measurement}
|
||||
type="number"
|
||||
></ha-textfield>
|
||||
>
|
||||
${stateObj.attributes.unit_of_measurement
|
||||
? html`<span slot="end"
|
||||
>${stateObj.attributes.unit_of_measurement}</span
|
||||
>`
|
||||
: nothing}
|
||||
</ha-input>
|
||||
</div>`}
|
||||
`;
|
||||
}
|
||||
@@ -323,7 +329,7 @@ class EntityPreviewRow extends LitElement {
|
||||
|
||||
if (domain === "text") {
|
||||
return html`
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
.label=${computeStateName(stateObj)}
|
||||
.disabled=${isUnavailableState(stateObj.state)}
|
||||
.value=${stateObj.state}
|
||||
@@ -333,7 +339,7 @@ class EntityPreviewRow extends LitElement {
|
||||
.pattern=${stateObj.attributes.pattern}
|
||||
.type=${stateObj.attributes.mode}
|
||||
placeholder=${this.hass!.localize("ui.card.text.emtpy_value")}
|
||||
></ha-textfield>
|
||||
></ha-input>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import "../../components/ha-area-picker";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/input/ha-input";
|
||||
import type { HaInput } from "../../components/input/ha-input";
|
||||
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
|
||||
import { getConfigEntries } from "../../data/config_entries";
|
||||
import type { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow";
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
type EntityRegistryDisplayEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import { showAlertDialog } from "../generic/show-dialog-box";
|
||||
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
|
||||
@@ -162,7 +164,7 @@ class StepFlowCreateEntry extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
.label=${localize(
|
||||
"ui.panel.config.integrations.config_flow.device_name"
|
||||
)}
|
||||
@@ -174,7 +176,7 @@ class StepFlowCreateEntry extends LitElement {
|
||||
computeDeviceName(device)}
|
||||
@change=${this._deviceNameChanged}
|
||||
.device=${device.id}
|
||||
></ha-textfield>
|
||||
></ha-input>
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
.device=${device.id}
|
||||
@@ -278,7 +280,7 @@ class StepFlowCreateEntry extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _areaPicked(ev: CustomEvent) {
|
||||
private async _areaPicked(ev: ValueChangedEvent<string>) {
|
||||
const picker = ev.currentTarget as any;
|
||||
const device = picker.device;
|
||||
const area = ev.detail.value;
|
||||
@@ -290,9 +292,9 @@ class StepFlowCreateEntry extends LitElement {
|
||||
this.requestUpdate("_deviceUpdate");
|
||||
}
|
||||
|
||||
private _deviceNameChanged(ev): void {
|
||||
const picker = ev.currentTarget as any;
|
||||
const device = picker.device;
|
||||
private _deviceNameChanged(ev: InputEvent): void {
|
||||
const picker = ev.currentTarget as HaInput;
|
||||
const device = (picker as any).device;
|
||||
const name = picker.value;
|
||||
|
||||
if (!(device in this._deviceUpdate)) {
|
||||
@@ -343,12 +345,11 @@ class StepFlowCreateEntry extends LitElement {
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-textfield,
|
||||
ha-area-picker {
|
||||
display: block;
|
||||
}
|
||||
ha-textfield {
|
||||
margin: 8px 0;
|
||||
ha-input {
|
||||
margin: var(--ha-space-2) 0;
|
||||
}
|
||||
.buttons > *:last-child {
|
||||
margin-left: auto;
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-control-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-textfield";
|
||||
import "../../components/ha-dialog";
|
||||
import type { HaTextField } from "../../components/ha-textfield";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/input/ha-input";
|
||||
import type { HaInput } from "../../components/input/ha-input";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../make-dialog-manager";
|
||||
import type { EnterCodeDialogParams } from "./show-enter-code-dialog";
|
||||
@@ -39,7 +39,7 @@ export class DialogEnterCode
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@query("#code") private _input?: HaTextField;
|
||||
@query("#code") private _input?: HaInput;
|
||||
|
||||
@state() private _showClearButton = false;
|
||||
|
||||
@@ -97,7 +97,7 @@ export class DialogEnterCode
|
||||
}
|
||||
|
||||
private _inputValueChange(e) {
|
||||
const field = e.currentTarget as HaTextField;
|
||||
const field = e.currentTarget as HaInput;
|
||||
const val = field.value;
|
||||
this._showClearButton = !!val;
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export class DialogEnterCode
|
||||
width="small"
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
class="input"
|
||||
?autofocus=${!this._narrow}
|
||||
id="code"
|
||||
@@ -129,7 +129,7 @@ export class DialogEnterCode
|
||||
validateOnInitialRender
|
||||
pattern=${ifDefined(this._dialogParams.codePattern)}
|
||||
inputmode="text"
|
||||
></ha-textfield>
|
||||
></ha-input>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@@ -157,14 +157,15 @@ export class DialogEnterCode
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div class="container">
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
@input=${this._inputValueChange}
|
||||
id="code"
|
||||
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
?autofocus=${!this._narrow}
|
||||
></ha-textfield>
|
||||
password-toggle
|
||||
></ha-input>
|
||||
<div class="keypad">
|
||||
${BUTTONS.map((value) =>
|
||||
value === ""
|
||||
@@ -212,7 +213,7 @@ export class DialogEnterCode
|
||||
/* Place above other dialogs */
|
||||
--dialog-z-index: 104;
|
||||
}
|
||||
ha-textfield {
|
||||
ha-input {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog-header";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-textfield";
|
||||
import type { HaTextField } from "../../components/ha-textfield";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/input/ha-input";
|
||||
import type { HaInput } from "../../components/input/ha-input";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { DialogBoxParams } from "./show-dialog-box";
|
||||
|
||||
@@ -28,7 +28,7 @@ class DialogBox extends LitElement {
|
||||
|
||||
@state() private _validInput = true;
|
||||
|
||||
@query("ha-textfield") private _textField?: HaTextField;
|
||||
@query("ha-input") private _textField?: HaInput;
|
||||
|
||||
private _closePromise?: Promise<void>;
|
||||
|
||||
@@ -109,14 +109,13 @@ class DialogBox extends LitElement {
|
||||
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}
|
||||
${this._params.prompt
|
||||
? html`
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
autofocus
|
||||
value=${ifDefined(this._params.defaultValue)}
|
||||
.placeholder=${this._params.placeholder}
|
||||
.label=${this._params.inputLabel
|
||||
? this._params.inputLabel
|
||||
: ""}
|
||||
.suffix=${this._params.inputSuffix}
|
||||
.type=${this._params.inputType
|
||||
? this._params.inputType
|
||||
: "text"}
|
||||
@@ -124,9 +123,13 @@ class DialogBox extends LitElement {
|
||||
.max=${this._params.inputMax}
|
||||
.disabled=${this._loading}
|
||||
@input=${this._validateInput}
|
||||
></ha-textfield>
|
||||
>
|
||||
${this._params.inputSuffix
|
||||
? html`<span slot="end">${this._params.inputSuffix}</span>`
|
||||
: nothing}
|
||||
</ha-input>
|
||||
`
|
||||
: ""}
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
${confirmPrompt
|
||||
@@ -240,7 +243,7 @@ class DialogBox extends LitElement {
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-textfield {
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
.title.alert {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
@@ -10,17 +9,10 @@ import {
|
||||
temperature2rgb,
|
||||
} from "../../../../common/color/convert-light-color";
|
||||
import { luminosity } from "../../../../common/color/rgb";
|
||||
import "../../../../components/ha-outlined-icon-button";
|
||||
import type { HaOutlinedIconButton } from "../../../../components/ha-outlined-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { LightColor, LightEntity } from "../../../../data/light";
|
||||
|
||||
@customElement("ha-favorite-color-button")
|
||||
class MoreInfoViewLightColorPicker extends LitElement {
|
||||
public override focus() {
|
||||
this._button?.focus();
|
||||
}
|
||||
|
||||
class HaFavoriteColorButton extends LitElement {
|
||||
@property({ attribute: false }) label?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
@@ -29,10 +21,12 @@ class MoreInfoViewLightColorPicker extends LitElement {
|
||||
|
||||
@property({ attribute: false }) color!: LightColor;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) wide = false;
|
||||
@query("button", true)
|
||||
private _button!: HTMLButtonElement;
|
||||
|
||||
@query("ha-outlined-icon-button", true)
|
||||
private _button?: HaOutlinedIconButton;
|
||||
public override focus() {
|
||||
this._button?.focus();
|
||||
}
|
||||
|
||||
private get _rgbColor(): [number, number, number] {
|
||||
if (this.color) {
|
||||
@@ -63,83 +57,64 @@ class MoreInfoViewLightColorPicker extends LitElement {
|
||||
protected render() {
|
||||
const backgroundColor = rgb2hex(this._rgbColor);
|
||||
const isLight = luminosity(this._rgbColor) > 0.8;
|
||||
const iconColor = isLight
|
||||
? ([33, 33, 33] as [number, number, number])
|
||||
: ([255, 255, 255] as [number, number, number]);
|
||||
const hexIconColor = rgb2hex(iconColor);
|
||||
const rgbIconColor = iconColor.join(", ");
|
||||
const borderColor = isLight ? "var(--divider-color)" : "transparent";
|
||||
|
||||
return html`
|
||||
<ha-outlined-icon-button
|
||||
no-ripple
|
||||
<button
|
||||
.disabled=${this.disabled}
|
||||
title=${ifDefined(this.label)}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
style=${styleMap({
|
||||
"background-color": backgroundColor,
|
||||
"--icon-color": hexIconColor,
|
||||
"--rgb-icon-color": rgbIconColor,
|
||||
"border-color": borderColor,
|
||||
"--focus-color": isLight ? borderColor : backgroundColor,
|
||||
})}
|
||||
></ha-outlined-icon-button>
|
||||
></button>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
ha-outlined-icon-button {
|
||||
--ha-icon-display: block;
|
||||
--md-sys-color-on-surface: var(
|
||||
--icon-color,
|
||||
var(--secondary-text-color)
|
||||
);
|
||||
--md-sys-color-on-surface-variant: var(
|
||||
--icon-color,
|
||||
var(--secondary-text-color)
|
||||
);
|
||||
--md-sys-color-on-surface-rgb: var(
|
||||
--rgb-icon-color,
|
||||
var(--rgb-secondary-text-color)
|
||||
);
|
||||
--md-sys-color-outline: var(--divider-color);
|
||||
--md-ripple-focus-color: 0;
|
||||
--md-ripple-hover-opacity: 0;
|
||||
--md-ripple-pressed-opacity: 0;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
:host([wide]) ha-outlined-icon-button {
|
||||
width: 100%;
|
||||
border-radius: var(--ha-favorite-color-button-border-radius);
|
||||
--_container-shape: var(--ha-favorite-color-button-border-radius);
|
||||
--_container-shape-start-start: var(
|
||||
--ha-favorite-color-button-border-radius
|
||||
);
|
||||
--_container-shape-start-end: var(
|
||||
--ha-favorite-color-button-border-radius
|
||||
);
|
||||
--_container-shape-end-start: var(
|
||||
--ha-favorite-color-button-border-radius
|
||||
);
|
||||
--_container-shape-end-end: var(
|
||||
--ha-favorite-color-button-border-radius
|
||||
);
|
||||
}
|
||||
:host([disabled]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-outlined-icon-button[disabled] {
|
||||
filter: grayscale(1) opacity(0.5);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
static readonly styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: var(--ha-favorite-color-button-size, 40px);
|
||||
height: var(--ha-favorite-color-button-size, 40px);
|
||||
}
|
||||
button {
|
||||
background-color: var(--color);
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(
|
||||
--ha-favorite-color-button-border-radius,
|
||||
var(--ha-border-radius-pill)
|
||||
);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition:
|
||||
box-shadow 180ms ease-in-out,
|
||||
transform 180ms ease-in-out;
|
||||
}
|
||||
button:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-color);
|
||||
}
|
||||
button:active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
:host([disabled]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
button:disabled {
|
||||
filter: grayscale(1) opacity(0.5);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-favorite-color-button": MoreInfoViewLightColorPicker;
|
||||
"ha-favorite-color-button": HaFavoriteColorButton;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { supportsFeature } from "../../../../common/entity/supports-feature";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-control-button";
|
||||
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-list-item";
|
||||
import "../../../../components/ha-select";
|
||||
import "../../../../components/ha-textfield";
|
||||
import "../../../../components/ha-dialog";
|
||||
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
|
||||
import "../../../../components/input/ha-input";
|
||||
import { SirenEntityFeature } from "../../../../data/siren";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -95,27 +95,31 @@ class MoreInfoSirenAdvancedControls extends LitElement {
|
||||
: nothing}
|
||||
${supportsVolume
|
||||
? html`
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
type="number"
|
||||
.label=${this.hass.localize("ui.components.siren.volume")}
|
||||
.suffix=${"%"}
|
||||
.value=${this._volume ? this._volume * 100 : undefined}
|
||||
.value=${this._volume ? `${this._volume * 100}` : undefined}
|
||||
@change=${this._handleVolumeChange}
|
||||
.min=${0}
|
||||
.max=${100}
|
||||
.step=${1}
|
||||
></ha-textfield>
|
||||
>
|
||||
<span slot="end">%</span>
|
||||
</ha-input>
|
||||
`
|
||||
: nothing}
|
||||
${supportsDuration
|
||||
? html`
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
type="number"
|
||||
.label=${this.hass.localize("ui.components.siren.duration")}
|
||||
.value=${this._duration}
|
||||
suffix="s"
|
||||
.value=${this._duration !== undefined
|
||||
? this._duration.toString()
|
||||
: undefined}
|
||||
@change=${this._handleDurationChange}
|
||||
></ha-textfield>
|
||||
>
|
||||
<span slot="end">s</span>
|
||||
</ha-input>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-control-button";
|
||||
import { UNAVAILABLE } from "../../../../data/entity/entity";
|
||||
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity/entity_attributes";
|
||||
import type {
|
||||
ExtEntityRegistryEntry,
|
||||
ValveEntityOptions,
|
||||
} from "../../../../data/entity/entity_registry";
|
||||
import { updateEntityRegistryEntry } from "../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ValveEntity } from "../../../../data/valve";
|
||||
import {
|
||||
DEFAULT_VALVE_FAVORITE_POSITIONS,
|
||||
normalizeValveFavoritePositions,
|
||||
} from "../../../../data/valve";
|
||||
import {
|
||||
showConfirmationDialog,
|
||||
showPromptDialog,
|
||||
} from "../../../generic/show-dialog-box";
|
||||
import "../ha-more-info-favorites";
|
||||
import type { HaMoreInfoFavorites } from "../ha-more-info-favorites";
|
||||
|
||||
type FavoriteLocalizeKey =
|
||||
| "set"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "delete_confirm_title"
|
||||
| "delete_confirm_text"
|
||||
| "delete_confirm_action"
|
||||
| "add"
|
||||
| "edit_title"
|
||||
| "add_title";
|
||||
|
||||
@customElement("ha-more-info-valve-favorite-positions")
|
||||
export class HaMoreInfoValveFavoritePositions extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public stateObj!: ValveEntity;
|
||||
|
||||
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
|
||||
|
||||
@property({ attribute: false }) public editMode?: boolean;
|
||||
|
||||
@state() private _favoritePositions: number[] = [];
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
if (
|
||||
(changedProps.has("entry") || changedProps.has("stateObj")) &&
|
||||
this.entry &&
|
||||
this.stateObj
|
||||
) {
|
||||
this._favoritePositions = normalizeValveFavoritePositions(
|
||||
this.entry.options?.valve?.favorite_positions ??
|
||||
DEFAULT_VALVE_FAVORITE_POSITIONS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _localizeFavorite(
|
||||
key: FavoriteLocalizeKey,
|
||||
values?: Record<string, string | number>
|
||||
): string {
|
||||
return this.hass.localize(
|
||||
`ui.dialogs.more_info_control.valve.favorite_position.${key}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
private _currentValue(): number | undefined {
|
||||
const current = this.stateObj.attributes.current_position;
|
||||
|
||||
return current == null ? undefined : Math.round(current);
|
||||
}
|
||||
|
||||
private async _save(favorite_positions: number[]): Promise<void> {
|
||||
if (!this.entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOptions: ValveEntityOptions = {
|
||||
...(this.entry.options?.valve ?? {}),
|
||||
};
|
||||
|
||||
currentOptions.favorite_positions = this._favoritePositions;
|
||||
|
||||
const result = await updateEntityRegistryEntry(
|
||||
this.hass,
|
||||
this.entry.entity_id,
|
||||
{
|
||||
options_domain: "valve",
|
||||
options: {
|
||||
...currentOptions,
|
||||
favorite_positions,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
fireEvent(this, "entity-entry-updated", result.entity_entry);
|
||||
}
|
||||
|
||||
private async _setFavorites(favorites: number[]): Promise<void> {
|
||||
const normalized = normalizeValveFavoritePositions(favorites);
|
||||
this._favoritePositions = normalized;
|
||||
await this._save(normalized);
|
||||
}
|
||||
|
||||
private _move(index: number, newIndex: number): void {
|
||||
const favorites = this._favoritePositions.concat();
|
||||
const moved = favorites.splice(index, 1)[0];
|
||||
favorites.splice(newIndex, 0, moved);
|
||||
this._setFavorites(favorites);
|
||||
}
|
||||
|
||||
private _applyFavorite(index: number): void {
|
||||
const favorite = this._favoritePositions[index];
|
||||
|
||||
if (favorite === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hass.callService("valve", "set_valve_position", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
position: favorite,
|
||||
});
|
||||
}
|
||||
|
||||
private async _promptFavoriteValue(
|
||||
value?: number
|
||||
): Promise<number | undefined> {
|
||||
const response = await showPromptDialog(this, {
|
||||
title: this._localizeFavorite(
|
||||
value === undefined ? "add_title" : "edit_title"
|
||||
),
|
||||
inputLabel: this.hass.localize("ui.card.valve.position"),
|
||||
inputType: "number",
|
||||
inputMin: "0",
|
||||
inputMax: "100",
|
||||
inputSuffix: DOMAIN_ATTRIBUTES_UNITS.valve.current_position,
|
||||
defaultValue: value === undefined ? undefined : String(value),
|
||||
});
|
||||
|
||||
if (response === null || response.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const number = Number(response);
|
||||
|
||||
if (isNaN(number)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(number)));
|
||||
}
|
||||
|
||||
private async _addFavorite(): Promise<void> {
|
||||
const value = await this._promptFavoriteValue();
|
||||
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._setFavorites([...this._favoritePositions, value]);
|
||||
}
|
||||
|
||||
private async _editFavorite(index: number): Promise<void> {
|
||||
const current = this._favoritePositions[index];
|
||||
|
||||
if (current === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = await this._promptFavoriteValue(current);
|
||||
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = [...this._favoritePositions];
|
||||
updated[index] = value;
|
||||
await this._setFavorites(updated);
|
||||
}
|
||||
|
||||
private async _deleteFavorite(index: number): Promise<void> {
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
destructive: true,
|
||||
title: this._localizeFavorite("delete_confirm_title"),
|
||||
text: this._localizeFavorite("delete_confirm_text"),
|
||||
confirmText: this._localizeFavorite("delete_confirm_action"),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._setFavorites(
|
||||
this._favoritePositions.filter((_, itemIndex) => itemIndex !== index)
|
||||
);
|
||||
}
|
||||
|
||||
private _renderFavorite: HaMoreInfoFavorites["renderItem"] = (
|
||||
favorite,
|
||||
_index,
|
||||
editMode
|
||||
) => {
|
||||
const value = favorite as number;
|
||||
const active = this._currentValue() === value;
|
||||
const label = this._localizeFavorite(editMode ? "edit" : "set", {
|
||||
value: `${value}%`,
|
||||
});
|
||||
|
||||
return html`
|
||||
<ha-control-button
|
||||
class=${classMap({
|
||||
active,
|
||||
})}
|
||||
style=${styleMap({
|
||||
"--control-button-border-radius": "var(--ha-border-radius-pill)",
|
||||
width: "72px",
|
||||
height: "36px",
|
||||
})}
|
||||
.label=${label}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
>
|
||||
${value}%
|
||||
</ha-control-button>
|
||||
`;
|
||||
};
|
||||
|
||||
private _deleteLabel = (index: number): string =>
|
||||
this._localizeFavorite("delete", {
|
||||
number: index + 1,
|
||||
});
|
||||
|
||||
private _handleFavoriteAction = (
|
||||
ev: HASSDomEvent<HASSDomEvents["favorite-item-action"]>
|
||||
): void => {
|
||||
ev.stopPropagation();
|
||||
|
||||
const { action, index } = ev.detail;
|
||||
|
||||
if (action === "hold" && this.hass.user?.is_admin) {
|
||||
fireEvent(this, "toggle-edit-mode", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.editMode) {
|
||||
this._editFavorite(index);
|
||||
return;
|
||||
}
|
||||
|
||||
this._applyFavorite(index);
|
||||
};
|
||||
|
||||
private _handleFavoriteMoved = (
|
||||
ev: HASSDomEvent<HASSDomEvents["favorite-item-moved"]>
|
||||
): void => {
|
||||
ev.stopPropagation();
|
||||
this._move(ev.detail.oldIndex, ev.detail.newIndex);
|
||||
};
|
||||
|
||||
private _handleFavoriteDelete = (
|
||||
ev: HASSDomEvent<HASSDomEvents["favorite-item-delete"]>
|
||||
): void => {
|
||||
ev.stopPropagation();
|
||||
this._deleteFavorite(ev.detail.index);
|
||||
};
|
||||
|
||||
private _handleFavoriteAdd = (
|
||||
ev: HASSDomEvent<HASSDomEvents["favorite-item-add"]>
|
||||
): void => {
|
||||
ev.stopPropagation();
|
||||
this._addFavorite();
|
||||
};
|
||||
|
||||
private _handleFavoriteDone = (
|
||||
ev: HASSDomEvent<HASSDomEvents["favorite-item-done"]>
|
||||
): void => {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "toggle-edit-mode", false);
|
||||
};
|
||||
|
||||
private _renderSection(): TemplateResult | typeof nothing {
|
||||
if (!this.editMode && this._favoritePositions.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<section class="group">
|
||||
<ha-more-info-favorites
|
||||
.items=${this._favoritePositions}
|
||||
.renderItem=${this._renderFavorite}
|
||||
.deleteLabel=${this._deleteLabel}
|
||||
.editMode=${this.editMode ?? false}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
.isAdmin=${Boolean(this.hass.user?.is_admin)}
|
||||
.showDone=${true}
|
||||
.addLabel=${this._localizeFavorite("add")}
|
||||
.doneLabel=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.exit_edit_mode"
|
||||
)}
|
||||
@favorite-item-action=${this._handleFavoriteAction}
|
||||
@favorite-item-moved=${this._handleFavoriteMoved}
|
||||
@favorite-item-delete=${this._handleFavoriteDelete}
|
||||
@favorite-item-add=${this._handleFavoriteAdd}
|
||||
@favorite-item-done=${this._handleFavoriteDone}
|
||||
></ha-more-info-favorites>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this.stateObj || !this.entry) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html` <div class="groups">${this._renderSection()}</div> `;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.group {
|
||||
width: 100%;
|
||||
max-width: 384px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.group ha-more-info-favorites {
|
||||
--favorite-items-max-width: 384px;
|
||||
--favorite-item-active-background-color: var(--state-valve-active-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-more-info-valve-favorite-positions": HaMoreInfoValveFavoritePositions;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,22 @@ import { CONTINUOUS_DOMAINS } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { isNumericEntity } from "../../data/history";
|
||||
|
||||
export const MORE_INFO_VIEWS = [
|
||||
"info",
|
||||
"history",
|
||||
"settings",
|
||||
"related",
|
||||
"add_to",
|
||||
"details",
|
||||
] as const;
|
||||
|
||||
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
|
||||
|
||||
export const isMoreInfoView = (
|
||||
value: string | undefined
|
||||
): value is MoreInfoView =>
|
||||
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
|
||||
|
||||
export const DOMAINS_NO_INFO = ["camera", "configurator"];
|
||||
/**
|
||||
* Entity domains that should be editable *if* they have an id present;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-markdown";
|
||||
import "../../../components/ha-textfield";
|
||||
import "../../../components/input/ha-input";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
@customElement("more-info-configurator")
|
||||
@@ -33,15 +33,15 @@ export class MoreInfoConfigurator extends LitElement {
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this.stateObj.attributes.errors}
|
||||
</ha-alert>`
|
||||
: ""}
|
||||
: nothing}
|
||||
${this.stateObj.attributes.fields.map(
|
||||
(field) =>
|
||||
html`<ha-textfield
|
||||
html`<ha-input
|
||||
.label=${field.name}
|
||||
.name=${field.id}
|
||||
.type=${field.type}
|
||||
@change=${this._fieldChanged}
|
||||
></ha-textfield>`
|
||||
></ha-input>`
|
||||
)}
|
||||
${this.stateObj.attributes.submit_caption
|
||||
? html`<p class="submit">
|
||||
@@ -53,7 +53,7 @@ export class MoreInfoConfigurator extends LitElement {
|
||||
${this.stateObj.attributes.submit_caption}
|
||||
</ha-button>
|
||||
</p>`
|
||||
: ""}
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user