mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-30 04:02:17 +00:00
Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba22a12a20 | |||
| 098b54f749 | |||
| 4c6a7091a6 | |||
| 322cb35526 | |||
| c34f6bea2b | |||
| 41bf0652b0 | |||
| 23af40743b | |||
| c4326b4f3a | |||
| d248f5614f | |||
| a4da7b26ea | |||
| 3c49cdf3c0 | |||
| 26af81d1a4 | |||
| 2a08f2d79b | |||
| a5be02b743 | |||
| 4228871f00 | |||
| 9a7a8fd377 | |||
| 8b82882e15 | |||
| 2701015eda | |||
| 1991a9e493 | |||
| 2b72c54194 | |||
| a7cb2fe7a7 | |||
| 51ea0c8201 | |||
| ead7081bc6 | |||
| ee982b1899 | |||
| e8b100a39e | |||
| 50c361db62 | |||
| e7a8d15a13 | |||
| fbd0409837 | |||
| a0d100611f | |||
| a969bf1065 | |||
| a153330610 | |||
| bd2f1ca3a8 | |||
| 3263034416 | |||
| 82b28b547a | |||
| 61c2c750b4 | |||
| 117690ee70 | |||
| e753de85eb | |||
| a240019968 | |||
| 0bdf4b8777 | |||
| 6337828ed8 | |||
| b8e5af652b | |||
| e4ae29e8b5 | |||
| 08231dbbb0 | |||
| 0ca656933d | |||
| b23cf8eba4 | |||
| 61b546415d | |||
| 4e1b709303 | |||
| 34e65b302d | |||
| 336d0e1b9d | |||
| 58d4cf8d84 | |||
| d3453aff37 | |||
| 64ff2e414c | |||
| 2ca25c980f | |||
| 73d93bc601 | |||
| 5ca6a8aced | |||
| 7ff4993e0b | |||
| 4e6fbacccc | |||
| 2958d49e36 | |||
| 92289dc7ea | |||
| f6c1a890e4 | |||
| d06321ed43 | |||
| 3c3d8d9974 | |||
| 4f39fa482d | |||
| 5d0fe3236c | |||
| b86142ae50 | |||
| 5d2f3ee5e8 | |||
| e3f7c631a7 | |||
| 49f9d95853 | |||
| db3d7701b5 | |||
| 3e55acf531 | |||
| f102618d9d | |||
| a3c02b511d | |||
| 74111d248e | |||
| f8161b3505 | |||
| 6070c1907a | |||
| ce5991582c | |||
| d17217fc90 | |||
| 86b4bd0013 | |||
| 108ba3abd6 | |||
| d38a2894c4 | |||
| 4c70376a62 | |||
| 8d69bd1401 | |||
| 5dfecd3693 | |||
| efd51d2234 | |||
| 668299c16a | |||
| 5e155a4030 | |||
| 809fa10135 | |||
| 1cbc38f231 | |||
| 9ed39bb523 | |||
| 4e3d66cf40 | |||
| 2eaad79d1c | |||
| afef7a2c0f | |||
| 18d5224002 | |||
| dbffdfeaca | |||
| 0a4b7917ab | |||
| e1524358d9 | |||
| 8774f9c3fc | |||
| f9a9aeacab | |||
| b798fee116 | |||
| b25f731f0f | |||
| 26a7372c5e | |||
| 70d3409d62 | |||
| 0711ecddab | |||
| bcfaa67eba | |||
| 1b60e6e04e | |||
| a1a634f6dc | |||
| 55f48fbb56 | |||
| ca4d66b94c | |||
| 51fd2eedd9 | |||
| 434a7c2e93 | |||
| b849fecf0b | |||
| 3a48e1996f | |||
| 8299386737 | |||
| 5e58ff476f | |||
| 758d955053 | |||
| 1efd5d26f0 | |||
| 36979f10cc | |||
| 812c59fcb4 | |||
| 0c34165bcf | |||
| 8c2bfbe9ce | |||
| 8f721d74e2 | |||
| 63782e6ef3 | |||
| eaad2295a9 | |||
| e74eee3d34 | |||
| cc39010839 | |||
| 7f97425214 | |||
| 8fac6e63de | |||
| 2ac8fe2b21 | |||
| 45ca1b2cdc | |||
| 0667f1e789 | |||
| db49678ccb | |||
| 2ca7e9f71e | |||
| 8d883450a8 | |||
| 2c136e00f5 | |||
| 6f82478598 | |||
| 1093bd890f | |||
| 456c638750 | |||
| 60ca50deb4 | |||
| 2064ab4141 | |||
| d34c42e587 | |||
| 5da7bf6fba | |||
| f05ff58d27 | |||
| 7b0a381d93 | |||
| 8b38e6d170 | |||
| 6daf0eb469 | |||
| 6f8f849af3 | |||
| cafe0f62c6 | |||
| 721cf46ce5 | |||
| 4e087760ab | |||
| 8fcfd4be84 | |||
| b03680a8ab | |||
| 7ab0622bec | |||
| c5aad44768 | |||
| 20ee7e5dc7 | |||
| 32fdcc708e | |||
| 7dd9b3308e | |||
| 71b870be15 | |||
| f08c5fa03a | |||
| fca408ae23 | |||
| f3a814e38a | |||
| 7b0e4651c4 | |||
| d98e373f64 | |||
| 649516c9fa | |||
| bbc4fb96b2 | |||
| 0ae639aeb0 | |||
| 0e7e41065e | |||
| 685843f112 | |||
| 5e1a99d94a | |||
| d843349865 | |||
| ec23164aa9 | |||
| e74ef11101 | |||
| a222f6a736 | |||
| ef3dd16d45 | |||
| 5d4e1d205e | |||
| 1ee5ebbe75 | |||
| 59d705aa3d | |||
| 332e108dae | |||
| 3c15b29d0a | |||
| 130c708e23 | |||
| 588a14a8a7 | |||
| a1ef6ad266 | |||
| a6c1f87730 | |||
| 49252a3808 | |||
| c7877fe38f | |||
| e355a61d8f | |||
| f2e19e51ce | |||
| fd9ab8f561 | |||
| faa1b3c98f | |||
| acc4a84fc9 | |||
| 4d723dac37 | |||
| f1d4d0ef98 | |||
| 88180a2708 | |||
| 258d87e3d5 | |||
| 55f22ba61a | |||
| 812f3ca8b9 | |||
| 7f880d11a0 | |||
| 6b2452c538 | |||
| c2cbf8bd21 | |||
| 224bcece9c | |||
| dc84b7698f | |||
| bc22e6a9bd | |||
| d44874783a | |||
| 8d1bb5c867 | |||
| da1b528eee | |||
| 756138408a | |||
| 3c8f112565 | |||
| 2521f3dde4 | |||
| 56390aa01a | |||
| 9aac5b19da | |||
| 24afc3dc88 | |||
| 873c7b2947 | |||
| 648db4276b | |||
| f86c3e7856 | |||
| 1d0251cc28 | |||
| 518cf87847 | |||
| 81a9216c44 | |||
| f0e10e0058 | |||
| 5df8ea4f07 | |||
| 73f081f5cc | |||
| f0d1db1da6 | |||
| c658eb414b | |||
| bac493b72b |
@@ -89,13 +89,13 @@ jobs:
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
if-no-files-found: error
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: frontend-build
|
||||
path: hass_frontend/
|
||||
|
||||
@@ -36,14 +36,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
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@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
|
||||
# ℹ️ 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@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
|
||||
@@ -57,14 +57,14 @@ jobs:
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: wheels
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
|
||||
@@ -5,9 +5,38 @@ on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
|
||||
jobs:
|
||||
check-authorization:
|
||||
add-no-stale:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To comment on, label, and close issues
|
||||
# Only run if this is a Task issue type (from the issue form)
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -21,8 +21,8 @@ type DialogType =
|
||||
| "basic"
|
||||
| "basic-subtitle-below"
|
||||
| "basic-subtitle-above"
|
||||
| "allow-mode-change"
|
||||
| "form"
|
||||
| "form-block-mode"
|
||||
| "actions"
|
||||
| "large"
|
||||
| "small";
|
||||
@@ -69,8 +69,8 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<ha-button @click=${this._handleOpenDialog("form")}
|
||||
>Adaptive dialog with form</ha-button
|
||||
>
|
||||
<ha-button @click=${this._handleOpenDialog("form-block-mode")}
|
||||
>Adaptive dialog with form (block mode change)</ha-button
|
||||
<ha-button @click=${this._handleOpenDialog("allow-mode-change")}
|
||||
>Adaptive dialog with allow mode change</ha-button
|
||||
>
|
||||
<ha-button @click=${this._handleOpenDialog("actions")}
|
||||
>Adaptive dialog with actions</ha-button
|
||||
@@ -164,27 +164,15 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "form-block-mode"}
|
||||
header-title="Adaptive dialog with form (block mode change)"
|
||||
header-subtitle="This form will not reset when the viewport size changes"
|
||||
block-mode-change
|
||||
.allowModeChange=${this._openDialog === "allow-mode-change"}
|
||||
header-title="Adaptive dialog with allow mode change"
|
||||
header-subtitle="Resize the window while this dialog is open"
|
||||
@closed=${this._handleClosed}
|
||||
>
|
||||
<ha-form autofocus .schema=${SCHEMA}></ha-form>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
@click=${this._handleClosed}
|
||||
slot="secondaryAction"
|
||||
variant="plain"
|
||||
>Cancel</ha-button
|
||||
>
|
||||
<ha-button
|
||||
@click=${this._handleClosed}
|
||||
slot="primaryAction"
|
||||
variant="accent"
|
||||
>Submit</ha-button
|
||||
>
|
||||
</ha-dialog-footer>
|
||||
<div>
|
||||
This dialog can switch between dialog mode and bottom sheet mode
|
||||
while open.
|
||||
</div>
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
@@ -225,10 +213,9 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
The mode is determined automatically and updates when the window is
|
||||
resized. To prevent mode changes after the initial mount (useful for
|
||||
preventing form resets), use the <code>block-mode-change</code>
|
||||
attribute.
|
||||
By default, the mode is determined at mount time and then stays fixed
|
||||
while the dialog is open. To allow switching modes while the viewport
|
||||
changes, use the <code>allow-mode-change</code> attribute.
|
||||
</p>
|
||||
|
||||
<h3>Width</h3>
|
||||
@@ -399,10 +386,10 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Use the <code>block-mode-change</code> attribute when you want to
|
||||
prevent the dialog from switching modes after it's opened. This is
|
||||
especially useful for forms, as it prevents form data from being lost
|
||||
when users resize their browser window.
|
||||
Use the <code>allow-mode-change</code> attribute when you want the
|
||||
dialog to switch between modes as the viewport changes after opening.
|
||||
For forms, you can keep the default behavior to avoid resetting fields
|
||||
on resize.
|
||||
</p>
|
||||
|
||||
<h3>Example usage</h3>
|
||||
@@ -410,7 +397,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<pre><code><ha-adaptive-dialog
|
||||
.hass=\${this.hass}
|
||||
open
|
||||
width="medium"
|
||||
header-title="Dialog title"
|
||||
header-subtitle="Dialog subtitle"
|
||||
>
|
||||
@@ -427,23 +413,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog></code></pre>
|
||||
|
||||
<p>Example with <code>block-mode-change</code> for forms:</p>
|
||||
|
||||
<pre><code><ha-adaptive-dialog
|
||||
.hass=\${this.hass}
|
||||
open
|
||||
header-title="Edit configuration"
|
||||
block-mode-change
|
||||
>
|
||||
<ha-form .schema=\${schema} .data=\${data}></ha-form>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="secondaryAction" variant="plain"
|
||||
>Cancel</ha-button
|
||||
>
|
||||
<ha-button slot="primaryAction" variant="accent">Save</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog></code></pre>
|
||||
|
||||
<h3>API</h3>
|
||||
|
||||
<p>
|
||||
@@ -521,12 +490,10 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>block-mode-change</code></td>
|
||||
<td><code>allow-mode-change</code></td>
|
||||
<td>
|
||||
When set, the mode is determined at mount time based on the
|
||||
current screen size, but subsequent mode changes are blocked.
|
||||
Useful for preventing forms from resetting when the viewport
|
||||
size changes.
|
||||
When set, the dialog can switch between modes as the viewport
|
||||
size changes while it is open.
|
||||
</td>
|
||||
<td><code>false</code></td>
|
||||
<td><code>false</code>, <code>true</code></td>
|
||||
@@ -548,6 +515,14 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<td><code>--ha-dialog-surface-background</code></td>
|
||||
<td>Dialog/sheet background color.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-dialog-surface-backdrop-filter</code></td>
|
||||
<td>Dialog/sheet surface backdrop filter.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--dialog-box-shadow</code></td>
|
||||
<td>Dialog surface box shadow (dialog mode only).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-dialog-border-radius</code></td>
|
||||
<td>Border radius of the dialog surface (dialog mode only).</td>
|
||||
@@ -560,6 +535,34 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<td><code>--ha-dialog-hide-duration</code></td>
|
||||
<td>Hide animation duration (dialog mode only).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-dialog-scrim-backdrop-filter</code></td>
|
||||
<td>Dialog/sheet scrim backdrop filter.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--dialog-backdrop-filter</code></td>
|
||||
<td>Dialog/sheet scrim backdrop filter (legacy fallback).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--mdc-dialog-scrim-color</code></td>
|
||||
<td>Dialog/sheet scrim color (legacy compatibility).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-bottom-sheet-surface-background</code></td>
|
||||
<td>Bottom sheet background color (sheet mode only).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-bottom-sheet-surface-backdrop-filter</code></td>
|
||||
<td>Bottom sheet surface backdrop filter (sheet mode only).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-bottom-sheet-scrim-backdrop-filter</code></td>
|
||||
<td>Bottom sheet scrim backdrop filter (sheet mode only).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-bottom-sheet-scrim-color</code></td>
|
||||
<td>Bottom sheet scrim color (sheet mode only).</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -380,13 +380,29 @@ export class DemoHaDialog extends LitElement {
|
||||
<td><code>--ha-dialog-surface-background</code></td>
|
||||
<td>Dialog background color.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-dialog-surface-backdrop-filter</code></td>
|
||||
<td>Backdrop filter applied to the dialog surface.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--dialog-box-shadow</code></td>
|
||||
<td>Dialog surface box shadow.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ha-dialog-border-radius</code></td>
|
||||
<td>Border radius of the dialog surface.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--dialog-z-index</code></td>
|
||||
<td>Z-index for the dialog.</td>
|
||||
<td><code>--ha-dialog-scrim-backdrop-filter</code></td>
|
||||
<td>Backdrop filter applied to the dialog scrim.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--dialog-backdrop-filter</code></td>
|
||||
<td>Legacy fallback for the dialog scrim backdrop filter.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--mdc-dialog-scrim-color</code></td>
|
||||
<td>Dialog scrim color (legacy compatibility).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--dialog-surface-margin-top</code></td>
|
||||
|
||||
@@ -222,6 +222,9 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
ha-language-picker {
|
||||
min-width: 200px;
|
||||
}
|
||||
ha-alert p {
|
||||
text-align: unset;
|
||||
}
|
||||
|
||||
+24
-25
@@ -30,14 +30,14 @@
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
"@codemirror/commands": "6.10.2",
|
||||
"@codemirror/language": "6.12.1",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.39.12",
|
||||
"@codemirror/view": "6.39.15",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.2.1",
|
||||
"@formatjs/intl-datetimeformat": "7.2.2",
|
||||
"@formatjs/intl-displaynames": "7.2.1",
|
||||
"@formatjs/intl-durationformat": "0.10.1",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.1",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/luxon3": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@home-assistant/webawesome": "3.2.1-ha.0",
|
||||
"@home-assistant/webawesome": "3.2.1-ha.3",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -68,7 +68,6 @@
|
||||
"@material/mwc-fab": "0.27.0",
|
||||
"@material/mwc-floating-label": "0.27.0",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-icon-button": "0.27.0",
|
||||
"@material/mwc-linear-progress": "0.27.0",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/mwc-radio": "0.27.0",
|
||||
@@ -84,7 +83,7 @@
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.18",
|
||||
"@swc/helpers": "0.5.19",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "3.9.1",
|
||||
"@tsparticles/preset-links": "3.2.0",
|
||||
@@ -93,7 +92,7 @@
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"app-datepicker": "5.1.1",
|
||||
"barcode-detector": "3.0.8",
|
||||
"barcode-detector": "3.1.0",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.48.0",
|
||||
@@ -107,7 +106,7 @@
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.1.0",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "6.0.2",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.15",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
@@ -119,7 +118,7 @@
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "17.0.1",
|
||||
"marked": "17.0.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -149,14 +148,14 @@
|
||||
"@babel/helper-define-polyfill-provider": "0.6.6",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.21.9",
|
||||
"@html-eslint/eslint-plugin": "0.55.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.21.10",
|
||||
"@html-eslint/eslint-plugin": "0.57.1",
|
||||
"@lokalise/node-api": "15.6.1",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.2",
|
||||
"@rspack/core": "1.7.5",
|
||||
"@rspack/core": "1.7.6",
|
||||
"@rspack/dev-server": "1.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.25",
|
||||
@@ -173,7 +172,7 @@
|
||||
"@types/mocha": "10.0.10",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "6.1.13",
|
||||
"@types/tar": "7.0.87",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
@@ -181,27 +180,27 @@
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
"del": "8.0.1",
|
||||
"eslint": "9.39.2",
|
||||
"eslint": "9.39.3",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.10",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-lit": "2.1.1",
|
||||
"eslint-plugin-lit": "2.2.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.3.0",
|
||||
"eslint-plugin-wc": "3.0.2",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.3",
|
||||
"glob": "13.0.1",
|
||||
"glob": "13.0.6",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "28.0.0",
|
||||
"jsdom": "28.1.0",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.2.7",
|
||||
"lint-staged": "16.3.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.5.0",
|
||||
@@ -211,12 +210,12 @@
|
||||
"rspack-manifest-plugin": "5.2.1",
|
||||
"serve": "14.2.5",
|
||||
"sinon": "21.0.1",
|
||||
"tar": "7.5.7",
|
||||
"tar": "7.5.9",
|
||||
"terser-webpack-plugin": "5.3.16",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.54.0",
|
||||
"vite-tsconfig-paths": "6.0.5",
|
||||
"typescript-eslint": "8.56.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.0.18",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
@@ -236,6 +235,6 @@
|
||||
},
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { genClientId } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-alert";
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
DataEntryFlowStepForm,
|
||||
} from "../data/data_entry_flow";
|
||||
import "./ha-auth-form";
|
||||
import type { HaAuthForm } from "./ha-auth-form";
|
||||
|
||||
type State = "loading" | "error" | "step";
|
||||
|
||||
@@ -52,6 +53,8 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@query("ha-auth-form") private _form?: HaAuthForm;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -179,7 +182,7 @@ export class HaAuthFlow extends LitElement {
|
||||
<div class="action">
|
||||
<ha-button
|
||||
@click=${this._handleSubmit}
|
||||
.disabled=${this._submitting}
|
||||
.loading=${this._submitting}
|
||||
>
|
||||
${this.step.type === "form"
|
||||
? this.localize("ui.panel.page-authorize.form.next")
|
||||
@@ -370,6 +373,11 @@ export class HaAuthFlow extends LitElement {
|
||||
this._providerChanged(this.authProvider);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._form?.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._submitting = true;
|
||||
|
||||
const postData = { ...this._stepData, client_id: this.clientId };
|
||||
|
||||
@@ -12,6 +12,10 @@ export class HaAuthFormString extends HaFormString {
|
||||
return this;
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this.querySelector("ha-auth-textfield")?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<style>
|
||||
@@ -31,7 +35,7 @@ export class HaAuthFormString extends HaFormString {
|
||||
right: 8px;
|
||||
inset-inline-start: initial;
|
||||
inset-inline-end: 8px;
|
||||
--mdc-icon-button-size: 40px;
|
||||
--ha-icon-button-size: 40px;
|
||||
--mdc-icon-size: 20px;
|
||||
color: var(--secondary-text-color);
|
||||
direction: var(--direction);
|
||||
|
||||
@@ -210,3 +210,39 @@ const formatDateWeekdayShortMem = memoizeOne(
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Mon, Aug 10
|
||||
export const formatDateWeekdayVeryShortDate = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) =>
|
||||
formatDateWeekdayVeryShortDateMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatDateWeekdayVeryShortDateMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Mon, Aug 10, 2021
|
||||
export const formatDateWeekdayShortDate = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) => formatDateWeekdayShortDateMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatDateWeekdayShortDateMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -133,33 +133,34 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
),
|
||||
});
|
||||
} catch (_err) {
|
||||
// fallback to default
|
||||
// fallback to default numeric formatting below
|
||||
}
|
||||
|
||||
const TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
};
|
||||
if (parts.length) {
|
||||
const TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
};
|
||||
|
||||
const valueParts: ValuePart[] = [];
|
||||
const valueParts: ValuePart[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const type = TYPE_MAP[part.type];
|
||||
if (!type) continue;
|
||||
const last = valueParts[valueParts.length - 1];
|
||||
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
|
||||
if (type === "value" && last?.type === "value") {
|
||||
last.value += part.value;
|
||||
} else {
|
||||
valueParts.push({ type, value: part.value });
|
||||
for (const part of parts) {
|
||||
const type = TYPE_MAP[part.type];
|
||||
if (!type) continue;
|
||||
const last = valueParts[valueParts.length - 1];
|
||||
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
|
||||
if (type === "value" && last?.type === "value") {
|
||||
last.value += part.value;
|
||||
} else {
|
||||
valueParts.push({ type, value: part.value });
|
||||
}
|
||||
}
|
||||
return valueParts;
|
||||
}
|
||||
|
||||
return valueParts;
|
||||
}
|
||||
|
||||
// default processing of numeric values
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, unsafeCSS } from "lit";
|
||||
import { normalizeSearchText, splitSearchTerms } from "./search-query";
|
||||
|
||||
export interface HighlightRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface NormalizedIndexMap {
|
||||
normalizedText: string;
|
||||
normalizedIndexMap: HighlightRange[];
|
||||
}
|
||||
|
||||
export type HighlightedText =
|
||||
| string
|
||||
| TemplateResult
|
||||
| (string | TemplateResult)[]
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const HIGHLIGHT_NAME_PREFIX = "ha-search";
|
||||
// Shared selector so range extraction and mutation checks stay in sync.
|
||||
const HIGHLIGHT_MARK_SELECTOR = "mark.ha-highlight";
|
||||
|
||||
const tokenizeSearchQuery = (query: string): string[] => [
|
||||
...new Set(splitSearchTerms(query)),
|
||||
];
|
||||
|
||||
/**
|
||||
* Build normalized text and an index map back to original indexes.
|
||||
* Needed because normalization can change character length/index positions.
|
||||
*/
|
||||
const buildNormalizedIndexMap = (
|
||||
text: string,
|
||||
language?: string
|
||||
): NormalizedIndexMap => {
|
||||
let normalizedText = "";
|
||||
const normalizedIndexMap: HighlightRange[] = [];
|
||||
|
||||
let originalIndex = 0;
|
||||
|
||||
for (const char of text) {
|
||||
const start = originalIndex;
|
||||
const end = start + char.length;
|
||||
const normalizedChar = normalizeSearchText(char, language);
|
||||
|
||||
normalizedText += normalizedChar;
|
||||
|
||||
// One original character can normalize into multiple UTF-16 code units.
|
||||
// Keep a mapping entry for each normalized code unit because String#indexOf
|
||||
// and String#length operate on UTF-16 indexes.
|
||||
for (const _codeUnit of normalizedChar.split("")) {
|
||||
normalizedIndexMap.push({ start, end });
|
||||
}
|
||||
|
||||
originalIndex = end;
|
||||
}
|
||||
|
||||
return { normalizedText, normalizedIndexMap };
|
||||
};
|
||||
|
||||
const mergeHighlightRanges = (ranges: HighlightRange[]): HighlightRange[] => {
|
||||
if (!ranges.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
|
||||
const mergedRanges: HighlightRange[] = [{ ...sortedRanges[0] }];
|
||||
|
||||
// Merge overlapping/adjacent ranges so the rendered marks stay minimal.
|
||||
for (let i = 1; i < sortedRanges.length; i++) {
|
||||
const previousRange = mergedRanges[mergedRanges.length - 1];
|
||||
const currentRange = sortedRanges[i];
|
||||
|
||||
if (currentRange.start <= previousRange.end) {
|
||||
previousRange.end = Math.max(previousRange.end, currentRange.end);
|
||||
continue;
|
||||
}
|
||||
|
||||
mergedRanges.push({ ...currentRange });
|
||||
}
|
||||
|
||||
return mergedRanges;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert rendered `<mark>` nodes into DOM Ranges for `CSS.highlights`.
|
||||
* We walk text nodes because Lit templates can place comment markers before
|
||||
* text inside `<mark>`, so `firstChild` is not reliably the text node.
|
||||
*/
|
||||
const getHighlightRangesFromMarks = (root: ShadowRoot): Range[] => {
|
||||
const ranges: Range[] = [];
|
||||
|
||||
root.querySelectorAll(HIGHLIGHT_MARK_SELECTOR).forEach((mark) => {
|
||||
const textWalker = document.createTreeWalker(mark, NodeFilter.SHOW_TEXT);
|
||||
let textNode = textWalker.nextNode();
|
||||
|
||||
while (textNode) {
|
||||
const text = textNode.textContent;
|
||||
if (text) {
|
||||
const range = new Range();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, text.length);
|
||||
ranges.push(range);
|
||||
}
|
||||
|
||||
textNode = textWalker.nextNode();
|
||||
}
|
||||
});
|
||||
|
||||
return ranges;
|
||||
};
|
||||
|
||||
const createHighlightStyle = (highlightName: string): string => css`
|
||||
.ha-highlight {
|
||||
/* Visual highlight comes from ::highlight(...), not the <mark> itself. */
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::highlight(${unsafeCSS(highlightName)}) {
|
||||
background-color: var(
|
||||
--ha-highlight-bg,
|
||||
var(--ha-color-fill-primary-normal-hover)
|
||||
);
|
||||
color: var(--ha-highlight-color, var(--primary-text-color));
|
||||
}
|
||||
`.cssText;
|
||||
|
||||
const renderHighlightedParts = (
|
||||
text: string,
|
||||
ranges: HighlightRange[]
|
||||
): (string | TemplateResult)[] => {
|
||||
const parts: (string | TemplateResult)[] = [];
|
||||
let previousIndex = 0;
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.start > previousIndex) {
|
||||
parts.push(text.slice(previousIndex, range.start));
|
||||
}
|
||||
|
||||
parts.push(
|
||||
html`<mark class="ha-highlight"
|
||||
>${text.slice(range.start, range.end)}</mark
|
||||
>`
|
||||
);
|
||||
|
||||
previousIndex = range.end;
|
||||
}
|
||||
|
||||
if (previousIndex < text.length) {
|
||||
parts.push(text.slice(previousIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search highlighting helper with two integration paths:
|
||||
* 1) call `renderHighlightedText` + `applyFromMarks` when updates are driven
|
||||
* by known state changes (like filter changes),
|
||||
* 2) call `startAutoSyncFromMarks` when highlighted DOM can change
|
||||
* independently of filter changes (like virtualized rows).
|
||||
*/
|
||||
export class SearchHighlight {
|
||||
// `CSS.highlights` is document-global, not per shadow root.
|
||||
// Each instance needs a unique key so that components do not overwrite each
|
||||
// other's highlight ranges.
|
||||
private static _nextHighlightId = 0;
|
||||
|
||||
// Fingerprints include Node identity, so map nodes to stable numeric IDs.
|
||||
private static _nodeIds = new WeakMap<Node, number>();
|
||||
|
||||
private static _nextNodeId = 0;
|
||||
|
||||
// Cache the last apply inputs to avoid re-registering identical highlights.
|
||||
private _lastCacheKey?: string;
|
||||
|
||||
private _lastFingerprint?: string;
|
||||
|
||||
private readonly _highlightName?: string;
|
||||
|
||||
private _autoSyncObserver?: MutationObserver;
|
||||
|
||||
private _autoSyncQueued = false;
|
||||
|
||||
private _autoSyncCacheKeyProvider?: () => string | null | undefined;
|
||||
|
||||
private _autoSyncObservedTarget?: Node;
|
||||
|
||||
public constructor(private readonly _root?: ShadowRoot) {
|
||||
if (this._root) {
|
||||
this._highlightName = `${HIGHLIGHT_NAME_PREFIX}-${SearchHighlight._nextHighlightId++}`;
|
||||
this._addHighlightStyle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return text ranges that should be highlighted for the given query.
|
||||
* Useful when the caller needs ranges without rendering `<mark>` output.
|
||||
*/
|
||||
public getHighlightRanges(
|
||||
text: string,
|
||||
query: string,
|
||||
language?: string
|
||||
): HighlightRange[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const terms = tokenizeSearchQuery(query);
|
||||
if (!terms.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { normalizedText, normalizedIndexMap } = buildNormalizedIndexMap(
|
||||
text,
|
||||
language
|
||||
);
|
||||
|
||||
// Text can normalize to empty (for example, combining marks only).
|
||||
if (!normalizedText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ranges: HighlightRange[] = [];
|
||||
|
||||
for (const term of terms) {
|
||||
const normalizedTerm = normalizeSearchText(term, language);
|
||||
// Some tokens normalize to empty (like combining marks); skip them.
|
||||
if (!normalizedTerm) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let matchIndex = normalizedText.indexOf(normalizedTerm);
|
||||
|
||||
while (matchIndex !== -1) {
|
||||
// Convert normalized-text match indexes back to original-text indexes.
|
||||
// `indexOf` guarantees the full normalized term is within bounds, and
|
||||
// we append one mapping item per normalized UTF-16 code unit.
|
||||
const start = normalizedIndexMap[matchIndex]!.start;
|
||||
const end =
|
||||
normalizedIndexMap[matchIndex + normalizedTerm.length - 1]!.end;
|
||||
ranges.push({ start, end });
|
||||
|
||||
matchIndex = normalizedText.indexOf(
|
||||
normalizedTerm,
|
||||
matchIndex + normalizedTerm.length
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return mergeHighlightRanges(ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render plain text with matching segments wrapped in `<mark>`.
|
||||
* `<mark>` nodes are used as stable anchors for range extraction.
|
||||
*/
|
||||
public renderHighlightedText(
|
||||
text: string | null | undefined,
|
||||
query: string | null | undefined,
|
||||
language?: string
|
||||
): HighlightedText {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const ranges = this.getHighlightRanges(text, query ?? "", language);
|
||||
if (!ranges.length) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return renderHighlightedParts(text, ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read rendered `<mark>` nodes from the root and apply matching
|
||||
* `CSS.highlights` ranges.
|
||||
* `cacheKey` should represent the current query/filter used to build marks.
|
||||
*/
|
||||
public applyFromMarks(cacheKey?: string): void {
|
||||
if (!this._root) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyFromRanges(getHighlightRangesFromMarks(this._root), cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply precomputed ranges directly to `CSS.highlights`.
|
||||
* Use this when ranges are built outside this class.
|
||||
* `cacheKey` should represent the inputs used to build `ranges`.
|
||||
*/
|
||||
public applyFromRanges(ranges: Range[], cacheKey?: string): void {
|
||||
if (!this._root || !this._highlightName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightRegistry = globalThis.CSS?.highlights;
|
||||
if (!highlightRegistry || typeof Highlight === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ranges.length) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerprint = this._getRangesFingerprint(ranges);
|
||||
// Skip writes only when both the caller key and concrete range positions
|
||||
// are unchanged.
|
||||
if (
|
||||
cacheKey === this._lastCacheKey &&
|
||||
fingerprint === this._lastFingerprint
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastCacheKey = cacheKey;
|
||||
this._lastFingerprint = fingerprint;
|
||||
|
||||
highlightRegistry.set(this._highlightName, new Highlight(...ranges));
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-sync `CSS.highlights` from `<mark>` nodes whenever marked DOM changes.
|
||||
* Use this for components where highlighted DOM can change without filter
|
||||
* changes (for example, virtualized lists).
|
||||
* `cacheKeyProvider` should return the current query/filter string.
|
||||
* `observedTarget` allows callers to scope observation to a subtree.
|
||||
*/
|
||||
public startAutoSyncFromMarks(
|
||||
cacheKeyProvider: () => string | null | undefined,
|
||||
observedTarget?: Node
|
||||
): void {
|
||||
if (!this._root || !this._highlightName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._autoSyncCacheKeyProvider = cacheKeyProvider;
|
||||
this._autoSyncObservedTarget = observedTarget ?? this._root;
|
||||
|
||||
if (!this._autoSyncObserver) {
|
||||
this._autoSyncObserver = new MutationObserver((records) => {
|
||||
if (
|
||||
!records.some((record) => this._mutationAffectsHighlights(record))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._queueAutoSyncFromMarks();
|
||||
});
|
||||
}
|
||||
|
||||
this._autoSyncObserver.disconnect();
|
||||
this._autoSyncObserver.observe(this._autoSyncObservedTarget, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
this._queueAutoSyncFromMarks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-sync started via `startAutoSyncFromMarks`.
|
||||
*/
|
||||
public stopAutoSyncFromMarks(): void {
|
||||
this._autoSyncObserver?.disconnect();
|
||||
this._autoSyncQueued = false;
|
||||
this._autoSyncCacheKeyProvider = undefined;
|
||||
this._autoSyncObservedTarget = undefined;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
if (!this._root) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.CSS?.highlights?.delete(this._highlightName!);
|
||||
this._lastCacheKey = undefined;
|
||||
this._lastFingerprint = undefined;
|
||||
}
|
||||
|
||||
private _getNodeId(node: Node): number {
|
||||
let nodeId = SearchHighlight._nodeIds.get(node);
|
||||
if (nodeId !== undefined) {
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
nodeId = SearchHighlight._nextNodeId++;
|
||||
SearchHighlight._nodeIds.set(node, nodeId);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable signature for a set of ranges so we can detect real range
|
||||
* changes even when the count stays the same.
|
||||
*/
|
||||
private _getRangesFingerprint(ranges: Range[]): string {
|
||||
return ranges
|
||||
.map((range) => {
|
||||
const startNodeId = this._getNodeId(range.startContainer);
|
||||
const endNodeId = this._getNodeId(range.endContainer);
|
||||
return `${startNodeId}:${range.startOffset}-${endNodeId}:${range.endOffset}`;
|
||||
})
|
||||
.join("|");
|
||||
}
|
||||
|
||||
private _queueAutoSyncFromMarks(): void {
|
||||
if (this._autoSyncQueued) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._autoSyncQueued = true;
|
||||
// Coalesce bursts of mutations into a single highlight recomputation.
|
||||
queueMicrotask(() => {
|
||||
this._autoSyncQueued = false;
|
||||
if (!this._root || !(this._root.host as HTMLElement).isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = this._autoSyncCacheKeyProvider?.()?.trim();
|
||||
if (!cacheKey) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyFromMarks(cacheKey);
|
||||
});
|
||||
}
|
||||
|
||||
private _mutationAffectsHighlights(mutation: MutationRecord): boolean {
|
||||
if (mutation.type === "characterData") {
|
||||
return this._nodeContainsHighlightMark(mutation.target);
|
||||
}
|
||||
|
||||
if (mutation.type !== "childList") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._nodeContainsHighlightMark(mutation.target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (this._nodeContainsHighlightMark(node)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (this._nodeContainsHighlightMark(node)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a node is a highlight mark, contains one, or is a text/comment
|
||||
* node inside one. The text/comment case covers Lit marker nodes.
|
||||
*/
|
||||
private _nodeContainsHighlightMark(node: Node): boolean {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
return (
|
||||
element.matches(HIGHLIGHT_MARK_SELECTOR) ||
|
||||
Boolean(element.querySelector(HIGHLIGHT_MARK_SELECTOR))
|
||||
);
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
||||
return Boolean(
|
||||
(node as DocumentFragment).querySelector?.(HIGHLIGHT_MARK_SELECTOR)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
node.nodeType === Node.TEXT_NODE ||
|
||||
node.nodeType === Node.COMMENT_NODE
|
||||
) {
|
||||
const parentElement = (node as ChildNode).parentElement;
|
||||
return Boolean(parentElement?.closest(HIGHLIGHT_MARK_SELECTOR));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject marker styles and `::highlight()` theme colors.
|
||||
*/
|
||||
private _addHighlightStyle(): void {
|
||||
if (!this._root || !this._highlightName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = createHighlightStyle(this._highlightName);
|
||||
this._root.appendChild(style);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { stripDiacritics } from "./strip-diacritics";
|
||||
|
||||
/**
|
||||
* Normalize text for search comparisons (case-insensitive + diacritics-insensitive).
|
||||
*/
|
||||
export const normalizeSearchText = (text: string, language?: string): string =>
|
||||
stripDiacritics(text).toLocaleLowerCase(language);
|
||||
|
||||
/**
|
||||
* Split a user query into whitespace-delimited search terms.
|
||||
*/
|
||||
export const splitSearchTerms = (query: string): string[] =>
|
||||
query.trim().split(/\s+/).filter(Boolean);
|
||||
@@ -1,3 +1,24 @@
|
||||
import { deepActiveElement } from "../dom/deep-active-element";
|
||||
|
||||
const getClipboardFallbackRoot = (): HTMLElement => {
|
||||
const activeElement = deepActiveElement();
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
let root: Node = activeElement.getRootNode();
|
||||
let host: HTMLElement | null = null;
|
||||
|
||||
while (root instanceof ShadowRoot && root.host instanceof HTMLElement) {
|
||||
host = root.host;
|
||||
root = root.host.getRootNode();
|
||||
}
|
||||
|
||||
if (host) {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
return document.body;
|
||||
};
|
||||
|
||||
export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
if (navigator.clipboard) {
|
||||
try {
|
||||
@@ -8,10 +29,15 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
}
|
||||
}
|
||||
|
||||
const root = rootEl ?? document.body;
|
||||
const root = rootEl || getClipboardFallbackRoot();
|
||||
|
||||
const el = document.createElement("textarea");
|
||||
el.value = str;
|
||||
el.setAttribute("readonly", "");
|
||||
el.style.position = "fixed";
|
||||
el.style.top = "0";
|
||||
el.style.left = "0";
|
||||
el.style.opacity = "0";
|
||||
root.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand("copy");
|
||||
|
||||
@@ -18,9 +18,11 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { getAllGraphColors } from "../../common/color/colors";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import { themesContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
@@ -28,8 +30,6 @@ import type { HomeAssistant } from "../../types";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import { formatTimeLabel } from "./axis-label";
|
||||
import { downSampleLineData } from "./down-sample";
|
||||
|
||||
@@ -1115,13 +1115,13 @@ export class HaChartBase extends LitElement {
|
||||
.chart-controls ::slotted(ha-icon-button) {
|
||||
background: var(--card-background-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--mdc-icon-button-size: 32px;
|
||||
--ha-icon-button-size: 32px;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
.chart-controls.small ha-icon-button,
|
||||
.chart-controls.small ::slotted(ha-icon-button) {
|
||||
--mdc-icon-button-size: 22px;
|
||||
--ha-icon-button-size: 22px;
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
.chart-controls ha-icon-button.inactive,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
|
||||
import { stateColorProperties } from "../../common/entity/state_color";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { computeCssValue } from "../../resources/css-variables";
|
||||
|
||||
@@ -32,6 +33,22 @@ function computeTimelineStateColor(
|
||||
return computeCssValue("--history-unknown-color", computedStyles);
|
||||
}
|
||||
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
|
||||
// Zone states for person/device_tracker don't have specific CSS color variables,
|
||||
// so they all fall back to the same --state-person-active-color.
|
||||
// Only use a custom CSS variable if explicitly defined (e.g. --state-person-kitchen-color),
|
||||
// otherwise return undefined to get unique colors from the generic color handler.
|
||||
if (
|
||||
(domain === "person" || domain === "device_tracker") &&
|
||||
!((FIXED_DOMAIN_STATES[domain] || []) as readonly string[]).includes(state)
|
||||
) {
|
||||
return computeCssValue(
|
||||
`--state-${domain}-${slugify(state, "_")}-color`,
|
||||
computedStyles
|
||||
);
|
||||
}
|
||||
|
||||
const properties = stateColorProperties(stateObj, state);
|
||||
|
||||
if (!properties) {
|
||||
@@ -41,8 +58,6 @@ function computeTimelineStateColor(
|
||||
const rgb = computeCssValue(properties, computedStyles);
|
||||
|
||||
if (!rgb) return undefined;
|
||||
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
const shade = DOMAIN_STATE_SHADES[domain]?.[state] as number | number;
|
||||
if (!shade) {
|
||||
return rgb;
|
||||
|
||||
@@ -32,11 +32,13 @@ export class DialogDataTableSettings extends LitElement {
|
||||
|
||||
@state() private _hiddenColumns?: string[];
|
||||
|
||||
private _lastFixedKeys: string[] = [];
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public showDialog(params: DataTableSettingsDialogParams) {
|
||||
this._params = params;
|
||||
this._columnOrder = params.columnOrder;
|
||||
this._columnOrder = this._preserveLastFixed(params.columnOrder);
|
||||
this._hiddenColumns = params.hiddenColumns;
|
||||
this._open = true;
|
||||
}
|
||||
@@ -50,6 +52,29 @@ export class DialogDataTableSettings extends LitElement {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _lastFixedCount(): number {
|
||||
const lastFixedKeys = Object.keys(this._params!.columns).filter(
|
||||
(col) => this._params!.columns[col].lastFixed
|
||||
);
|
||||
if (lastFixedKeys.length) {
|
||||
this._lastFixedKeys = lastFixedKeys;
|
||||
}
|
||||
return lastFixedKeys.length;
|
||||
}
|
||||
|
||||
private _preserveLastFixed(columnOrder) {
|
||||
let strippedColumnOrder;
|
||||
const lastFixedCount = this._lastFixedCount();
|
||||
if (lastFixedCount && columnOrder) {
|
||||
strippedColumnOrder = [...columnOrder];
|
||||
strippedColumnOrder.splice(
|
||||
columnOrder.length - lastFixedCount,
|
||||
lastFixedCount
|
||||
);
|
||||
}
|
||||
return strippedColumnOrder;
|
||||
}
|
||||
|
||||
private _sortedColumns = memoizeOne(
|
||||
(
|
||||
columns: DataTableColumnContainer,
|
||||
@@ -57,7 +82,7 @@ export class DialogDataTableSettings extends LitElement {
|
||||
hiddenColumns: string[] | undefined
|
||||
) =>
|
||||
Object.keys(columns)
|
||||
.filter((col) => !columns[col].hidden)
|
||||
.filter((col) => !columns[col].hidden && !columns[col].lastFixed)
|
||||
.sort((a, b) => {
|
||||
const orderA = columnOrder?.indexOf(a) ?? -1;
|
||||
const orderB = columnOrder?.indexOf(b) ?? -1;
|
||||
@@ -195,7 +220,8 @@ export class DialogDataTableSettings extends LitElement {
|
||||
|
||||
this._columnOrder = columnOrder;
|
||||
|
||||
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
|
||||
const reportedOrder = columnOrder.concat(this._lastFixedKeys);
|
||||
this._params!.onUpdate(reportedOrder, this._hiddenColumns);
|
||||
}
|
||||
|
||||
private _toggle(ev) {
|
||||
@@ -276,7 +302,8 @@ export class DialogDataTableSettings extends LitElement {
|
||||
|
||||
this._hiddenColumns = hidden;
|
||||
|
||||
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
|
||||
const reportedOrder = this._columnOrder.concat(this._lastFixedKeys);
|
||||
this._params!.onUpdate(reportedOrder, this._hiddenColumns);
|
||||
}
|
||||
|
||||
private _reset() {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
import { SearchHighlight } from "../../common/string/search-highlight";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { groupBy } from "../../common/util/group-by";
|
||||
@@ -87,6 +86,7 @@ export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
|
||||
flex?: number;
|
||||
forceLTR?: boolean;
|
||||
hidden?: boolean;
|
||||
lastFixed?: boolean;
|
||||
}
|
||||
|
||||
export type ClonedDataTableColumnData = Omit<DataTableColumnData, "title"> & {
|
||||
@@ -118,8 +118,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
|
||||
@property({ attribute: "has-fab", type: Boolean }) public hasFab = false;
|
||||
|
||||
/**
|
||||
* Add an extra row at the bottom of the data table
|
||||
* @type {TemplateResult}
|
||||
@@ -136,9 +134,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public searchLabel?: string;
|
||||
|
||||
@property({ type: Boolean, attribute: "no-label-float" })
|
||||
public noLabelFloat? = false;
|
||||
|
||||
@property({ type: String }) public filter = "";
|
||||
|
||||
@property({ attribute: false }) public groupColumn?: string;
|
||||
@@ -179,8 +174,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
private _lastUpdate = 0;
|
||||
|
||||
private _searchHighlight?: SearchHighlight;
|
||||
|
||||
// @ts-ignore
|
||||
@restoreScroll(".scroller") private _savedScrollPos?: number;
|
||||
|
||||
@@ -237,36 +230,21 @@ export class HaDataTable extends LitElement {
|
||||
// Force update of location of rows
|
||||
this._filteredData = [...this._filteredData];
|
||||
}
|
||||
|
||||
// Re-attach observer when the element reconnects.
|
||||
if (this.hasUpdated) {
|
||||
this._updateSearchHighlightSync();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._searchHighlight?.stopAutoSyncFromMarks();
|
||||
this._searchHighlight?.clear();
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.updateComplete.then(() => this._calcTableHeight());
|
||||
this._updateSearchHighlightSync();
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has("_filter")) {
|
||||
this._updateSearchHighlightSync();
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
|
||||
if (header) {
|
||||
if (header.scrollWidth > header.clientWidth) {
|
||||
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
|
||||
} else {
|
||||
this.style.removeProperty("--table-row-width");
|
||||
}
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
if (header.scrollWidth > header.clientWidth) {
|
||||
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
|
||||
} else {
|
||||
this.style.removeProperty("--table-row-width");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +355,11 @@ export class HaDataTable extends LitElement {
|
||||
.sort((a, b) => {
|
||||
const orderA = columnOrder!.indexOf(a);
|
||||
const orderB = columnOrder!.indexOf(b);
|
||||
const fixedA = Boolean(columns[a].lastFixed);
|
||||
const fixedB = Boolean(columns[b].lastFixed);
|
||||
if (fixedA !== fixedB) {
|
||||
return fixedA ? 1 : -1;
|
||||
}
|
||||
if (orderA !== orderB) {
|
||||
if (orderA === -1) {
|
||||
return 1;
|
||||
@@ -412,7 +395,6 @@ export class HaDataTable extends LitElement {
|
||||
.hass=${this.hass}
|
||||
@value-changed=${this._handleSearchChange}
|
||||
.label=${this.searchLabel}
|
||||
.noLabelFloat=${this.noLabelFloat}
|
||||
></search-input>
|
||||
</div>
|
||||
`
|
||||
@@ -446,9 +428,9 @@ export class HaDataTable extends LitElement {
|
||||
<ha-checkbox
|
||||
class="mdc-data-table__row-checkbox"
|
||||
@change=${this._handleHeaderRowCheckboxClick}
|
||||
.indeterminate=${this._checkedRows.length &&
|
||||
.indeterminate=${!!this._checkedRows.length &&
|
||||
this._checkedRows.length !== this._checkableRowsCount}
|
||||
.checked=${this._checkedRows.length &&
|
||||
.checked=${!!this._checkedRows.length &&
|
||||
this._checkedRows.length === this._checkableRowsCount}
|
||||
>
|
||||
</ha-checkbox>
|
||||
@@ -535,7 +517,6 @@ export class HaDataTable extends LitElement {
|
||||
this._filteredData,
|
||||
localize,
|
||||
this.appendRow,
|
||||
this.hasFab,
|
||||
this.groupColumn,
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
@@ -638,12 +619,7 @@ export class HaDataTable extends LitElement {
|
||||
${column.template
|
||||
? column.template(row)
|
||||
: narrow && column.main
|
||||
? html`<div class="primary">
|
||||
${this._renderValueWithHighlight(
|
||||
row[key],
|
||||
column.filterable
|
||||
)}
|
||||
</div>
|
||||
? html`<div class="primary">${row[key]}</div>
|
||||
<div class="secondary">
|
||||
${Object.entries(columns)
|
||||
.filter(
|
||||
@@ -661,21 +637,15 @@ export class HaDataTable extends LitElement {
|
||||
([key2, column2], i) =>
|
||||
html`${i !== 0
|
||||
? STRINGS_SEPARATOR_DOT
|
||||
: nothing}${this._renderCellValue(
|
||||
column2,
|
||||
key2,
|
||||
row
|
||||
)}`
|
||||
: nothing}${column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]}`
|
||||
)}
|
||||
</div>
|
||||
${column.extraTemplate
|
||||
? column.extraTemplate(row)
|
||||
: nothing}`
|
||||
: html`${this._renderCellValue(
|
||||
column,
|
||||
key,
|
||||
row
|
||||
)}${column.extraTemplate
|
||||
: html`${row[key]}${column.extraTemplate
|
||||
? column.extraTemplate(row)
|
||||
: nothing}`}
|
||||
</div>
|
||||
@@ -685,69 +655,6 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _renderCellValue(
|
||||
column: DataTableColumnData,
|
||||
key: string,
|
||||
row: DataTableRowData
|
||||
) {
|
||||
if (column.template) {
|
||||
return column.template(row);
|
||||
}
|
||||
|
||||
return this._renderValueWithHighlight(row[key], column.filterable);
|
||||
}
|
||||
|
||||
private _renderValueWithHighlight(
|
||||
value: unknown,
|
||||
filterable?: boolean
|
||||
): unknown {
|
||||
if (!filterable) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const filter = this._filter.trim();
|
||||
if (!filter) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = String(value);
|
||||
return this._getSearchHighlight().renderHighlightedText(
|
||||
text,
|
||||
filter,
|
||||
this.hass.locale.language
|
||||
);
|
||||
}
|
||||
|
||||
private _getSearchHighlight(): SearchHighlight {
|
||||
if (!this._searchHighlight) {
|
||||
this._searchHighlight = new SearchHighlight(
|
||||
this.renderRoot as ShadowRoot
|
||||
);
|
||||
}
|
||||
return this._searchHighlight;
|
||||
}
|
||||
|
||||
private _updateSearchHighlightSync(): void {
|
||||
if (!this._filter.trim()) {
|
||||
this._searchHighlight?.stopAutoSyncFromMarks();
|
||||
this._searchHighlight?.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const observedTarget =
|
||||
this.renderRoot.querySelector("lit-virtualizer") ||
|
||||
(this.renderRoot as ShadowRoot);
|
||||
|
||||
this._getSearchHighlight().startAutoSyncFromMarks(
|
||||
() => this._filter,
|
||||
observedTarget
|
||||
);
|
||||
}
|
||||
|
||||
private async _sortFilterData() {
|
||||
const startTime = new Date().getTime();
|
||||
const timeBetweenUpdate = startTime - this._lastUpdate;
|
||||
@@ -806,14 +713,13 @@ export class HaDataTable extends LitElement {
|
||||
data: DataTableRowData[],
|
||||
localize: LocalizeFunc,
|
||||
appendRow,
|
||||
hasFab: boolean,
|
||||
groupColumn: string | undefined,
|
||||
groupOrder: string[] | undefined,
|
||||
collapsedGroups: string[],
|
||||
sortColumn: string | undefined,
|
||||
sortDirection: SortingDirection
|
||||
) => {
|
||||
if (appendRow || hasFab || groupColumn) {
|
||||
if (appendRow || groupColumn) {
|
||||
let items = [...data];
|
||||
|
||||
if (groupColumn) {
|
||||
@@ -903,13 +809,11 @@ export class HaDataTable extends LitElement {
|
||||
items.push({ append: true, selectable: false, content: appendRow });
|
||||
}
|
||||
|
||||
if (hasFab) {
|
||||
items.push({ empty: true });
|
||||
}
|
||||
items.push({ empty: true });
|
||||
|
||||
return items;
|
||||
}
|
||||
return data;
|
||||
return [...data, { empty: true }];
|
||||
}
|
||||
);
|
||||
|
||||
@@ -961,7 +865,6 @@ export class HaDataTable extends LitElement {
|
||||
this._filteredData,
|
||||
this.localizeFunc || this.hass.localize,
|
||||
this.appendRow,
|
||||
this.hasFab,
|
||||
this.groupColumn,
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
@@ -1179,11 +1082,8 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table__row.empty-row {
|
||||
height: max(
|
||||
var(
|
||||
--data-table-empty-row-height,
|
||||
var(--data-table-row-height, 52px)
|
||||
),
|
||||
height: var(
|
||||
--data-table-empty-row-height,
|
||||
var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@ import type { FuseOptionKey, IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ipCompare, stringCompare } from "../../common/string/compare";
|
||||
import {
|
||||
normalizeSearchText,
|
||||
splitSearchTerms,
|
||||
} from "../../common/string/search-query";
|
||||
import { stripDiacritics } from "../../common/string/strip-diacritics";
|
||||
import type {
|
||||
ClonedDataTableColumnData,
|
||||
DataTableRowData,
|
||||
@@ -50,10 +47,10 @@ const getSearchableValue = (
|
||||
const stringValues = value
|
||||
.filter((item) => item != null && typeof item !== "object")
|
||||
.map(String);
|
||||
return normalizeSearchText(stringValues.join(" "));
|
||||
return stripDiacritics(stringValues.join(" ").toLowerCase());
|
||||
}
|
||||
|
||||
return normalizeSearchText(String(value));
|
||||
return stripDiacritics(String(value).toLowerCase());
|
||||
};
|
||||
|
||||
/** Filters data using exact substring matching (all terms must match). */
|
||||
@@ -144,7 +141,7 @@ const filterData = (
|
||||
columns: SortableColumnContainer,
|
||||
filter: string
|
||||
): DataTableRowData[] => {
|
||||
const normalizedFilter = normalizeSearchText(filter).trim();
|
||||
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
|
||||
|
||||
if (!normalizedFilter) {
|
||||
return data;
|
||||
@@ -156,7 +153,7 @@ const filterData = (
|
||||
return data;
|
||||
}
|
||||
|
||||
const terms = splitSearchTerms(normalizedFilter);
|
||||
const terms = normalizedFilter.split(/\s+/);
|
||||
|
||||
// First, try exact substring matching
|
||||
const exactMatches = filterDataExact(data, filterKeys, terms);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
@customElement("ha-entity-attribute-picker")
|
||||
class HaEntityAttributePicker extends LitElement {
|
||||
@@ -94,12 +95,19 @@ class HaEntityAttributePicker extends LitElement {
|
||||
.helper=${this.helper}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.getItems=${this._getItems}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value: string) => {
|
||||
const items = this._getItems();
|
||||
const item = items.find((option) => option.id === value);
|
||||
return html`<span slot="headline">${item?.primary ?? value}</span>`;
|
||||
};
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const newValue = ev.detail.value;
|
||||
|
||||
@@ -164,7 +164,7 @@ export class HaEntityToggle extends LitElement {
|
||||
min-width: 38px;
|
||||
}
|
||||
ha-icon-button {
|
||||
--mdc-icon-button-size: 40px;
|
||||
--ha-icon-button-size: 40px;
|
||||
color: var(--ha-icon-button-inactive-color, var(--primary-text-color));
|
||||
transition: color 0.5s;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { iconColorCSS } from "../../common/style/icon_color_css";
|
||||
import { cameraUrlWithWidthHeight } from "../../data/camera";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { addBrandsAuth } from "../../util/brands-url";
|
||||
import "../ha-state-icon";
|
||||
|
||||
@customElement("state-badge")
|
||||
@@ -137,6 +138,7 @@ export class StateBadge extends LitElement {
|
||||
let imageUrl =
|
||||
stateObj.attributes.entity_picture_local ||
|
||||
stateObj.attributes.entity_picture;
|
||||
imageUrl = addBrandsAuth(imageUrl);
|
||||
if (this.hass) {
|
||||
imageUrl = this.hass.hassUrl(imageUrl);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,18 @@ type DialogSheetMode = "dialog" | "bottom-sheet";
|
||||
* @slot footer - Dialog/sheet footer content.
|
||||
*
|
||||
* @cssprop --ha-dialog-surface-background - Dialog/sheet background color.
|
||||
* @cssprop --ha-dialog-surface-backdrop-filter - Dialog/sheet backdrop filter.
|
||||
* @cssprop --dialog-box-shadow - Dialog box shadow (dialog mode only).
|
||||
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface (dialog mode only).
|
||||
* @cssprop --ha-dialog-show-duration - Show animation duration (dialog mode only).
|
||||
* @cssprop --ha-dialog-hide-duration - Hide animation duration (dialog mode only).
|
||||
* @cssprop --ha-dialog-scrim-backdrop-filter - Dialog/sheet scrim backdrop filter.
|
||||
* @cssprop --dialog-backdrop-filter - Dialog/sheet scrim backdrop filter (legacy).
|
||||
* @cssprop --mdc-dialog-scrim-color - Dialog/sheet scrim color (legacy).
|
||||
* @cssprop --ha-bottom-sheet-surface-background - Bottom sheet background color (sheet mode only).
|
||||
* @cssprop --ha-bottom-sheet-surface-backdrop-filter - Bottom sheet backdrop filter (sheet mode only).
|
||||
* @cssprop --ha-bottom-sheet-scrim-backdrop-filter - Bottom sheet scrim backdrop filter (sheet mode only).
|
||||
* @cssprop --ha-bottom-sheet-scrim-color - Bottom sheet scrim color (sheet mode only).
|
||||
*
|
||||
* @attr {boolean} open - Controls the dialog/sheet open state.
|
||||
* @attr {("alert"|"standard")} type - Dialog type (dialog mode only). Defaults to "standard".
|
||||
@@ -220,6 +229,7 @@ export class HaAdaptiveDialog extends LitElement {
|
||||
return [
|
||||
css`
|
||||
ha-bottom-sheet {
|
||||
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
|
||||
--ha-bottom-sheet-surface-background: var(
|
||||
--ha-dialog-surface-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default))
|
||||
|
||||
@@ -135,7 +135,7 @@ class HaAlert extends LitElement {
|
||||
}
|
||||
.action ha-icon-button {
|
||||
--mdc-theme-primary: var(--primary-text-color);
|
||||
--mdc-icon-button-size: 36px;
|
||||
--ha-icon-button-size: 36px;
|
||||
}
|
||||
.issue-type.info > .icon {
|
||||
color: var(--info-color);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
state as litState,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { SearchHighlight } from "../common/string/search-highlight";
|
||||
|
||||
interface State {
|
||||
bold: boolean;
|
||||
@@ -34,8 +33,6 @@ export class HaAnsiToHtml extends LitElement {
|
||||
|
||||
@litState() private _filter = "";
|
||||
|
||||
private _searchHighlight?: SearchHighlight;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
|
||||
}
|
||||
@@ -49,11 +46,6 @@ export class HaAnsiToHtml extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._searchHighlight?.clear();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
pre {
|
||||
margin: 0;
|
||||
@@ -122,6 +114,11 @@ export class HaAnsiToHtml extends LitElement {
|
||||
.bg-white {
|
||||
background-color: rgb(204, 204, 204);
|
||||
}
|
||||
|
||||
::highlight(search-results) {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
@@ -326,29 +323,30 @@ export class HaAnsiToHtml extends LitElement {
|
||||
this._filter = filter;
|
||||
const lines = this.shadowRoot?.querySelectorAll("div") || [];
|
||||
let numberOfFoundLines = 0;
|
||||
const filterLower = filter.toLowerCase();
|
||||
if (!filter) {
|
||||
lines.forEach((line) => {
|
||||
line.style.display = "";
|
||||
});
|
||||
numberOfFoundLines = lines.length;
|
||||
this._searchHighlight?.clear();
|
||||
if (CSS.highlights) {
|
||||
CSS.highlights.delete("search-results");
|
||||
}
|
||||
} else {
|
||||
const highlightRanges: Range[] = [];
|
||||
lines.forEach((line) => {
|
||||
if (!line.textContent?.toLowerCase().includes(filterLower)) {
|
||||
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
line.style.display = "none";
|
||||
} else {
|
||||
line.style.display = "";
|
||||
numberOfFoundLines++;
|
||||
if (line.firstChild !== null && line.textContent) {
|
||||
if (CSS.highlights && line.firstChild !== null && line.textContent) {
|
||||
const spansOfLine = line.querySelectorAll("span");
|
||||
spansOfLine.forEach((span) => {
|
||||
const text = span.textContent.toLowerCase();
|
||||
const indices: number[] = [];
|
||||
let startPos = 0;
|
||||
while (startPos < text.length) {
|
||||
const index = text.indexOf(filterLower, startPos);
|
||||
const index = text.indexOf(filter.toLowerCase(), startPos);
|
||||
if (index === -1) break;
|
||||
indices.push(index);
|
||||
startPos = index + filter.length;
|
||||
@@ -364,11 +362,8 @@ export class HaAnsiToHtml extends LitElement {
|
||||
}
|
||||
}
|
||||
});
|
||||
if (this.shadowRoot) {
|
||||
this._getSearchHighlight(this.shadowRoot).applyFromRanges(
|
||||
highlightRanges,
|
||||
filter
|
||||
);
|
||||
if (CSS.highlights) {
|
||||
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,13 +375,6 @@ export class HaAnsiToHtml extends LitElement {
|
||||
this._pre.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
private _getSearchHighlight(root: ShadowRoot): SearchHighlight {
|
||||
if (!this._searchHighlight) {
|
||||
this._searchHighlight = new SearchHighlight(root);
|
||||
}
|
||||
return this._searchHighlight;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -672,11 +672,11 @@ export class HaAssistChat extends LitElement {
|
||||
--markdown-code-background-color: var(--primary-background-color);
|
||||
--markdown-code-text-color: var(--primary-text-color);
|
||||
--markdown-list-indent: 1.15em;
|
||||
&:not(:has(ha-markdown-element)) {
|
||||
min-height: 1lh;
|
||||
min-width: 1lh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
ha-markdown:not(:has(ha-markdown-element)) {
|
||||
min-height: 1lh;
|
||||
min-width: 1lh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bouncer {
|
||||
width: 48px;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HaSelectSelectEvent } from "./ha-select";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-input-helper-text";
|
||||
import "./ha-select";
|
||||
import type { HaSelectSelectEvent } from "./ha-select";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
|
||||
@@ -133,6 +133,17 @@ export class HaBaseTimeInput extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
|
||||
|
||||
@queryAll("ha-textfield") private _inputs?: HaTextField[];
|
||||
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._inputs?.every((input) => input.reportValidity()) ?? true;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${this.label
|
||||
@@ -368,7 +379,7 @@ export class HaBaseTimeInput extends LitElement {
|
||||
}
|
||||
ha-icon-button {
|
||||
position: relative;
|
||||
--mdc-icon-button-size: 36px;
|
||||
--ha-icon-button-size: 36px;
|
||||
--mdc-icon-size: 20px;
|
||||
color: var(--secondary-text-color);
|
||||
direction: var(--direction);
|
||||
|
||||
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import type WaDrawer from "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
|
||||
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
@@ -11,6 +12,40 @@ import { isIosApp } from "../util/is_ios";
|
||||
|
||||
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
const SWIPE_LOCKED_COMPONENTS = new Set([
|
||||
"ha-control-slider",
|
||||
"ha-slider",
|
||||
"ha-control-switch",
|
||||
"ha-control-circular-slider",
|
||||
"ha-hs-color-picker",
|
||||
"ha-map",
|
||||
"ha-more-info-control-select-container",
|
||||
"ha-filter-chip",
|
||||
]);
|
||||
|
||||
const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
|
||||
|
||||
/**
|
||||
* Home Assistant bottom sheet component.
|
||||
*
|
||||
* @element ha-bottom-sheet
|
||||
* @extends {LitElement}
|
||||
*
|
||||
* @cssprop --ha-bottom-sheet-height - Preferred height of the bottom sheet.
|
||||
* @cssprop --ha-bottom-sheet-max-height - Maximum height of the bottom sheet.
|
||||
* @cssprop --ha-bottom-sheet-max-width - Maximum width of the bottom sheet.
|
||||
* @cssprop --ha-bottom-sheet-border-radius - Top border radius of the bottom sheet.
|
||||
* @cssprop --ha-bottom-sheet-surface-background - Bottom sheet background color.
|
||||
* @cssprop --ha-bottom-sheet-surface-backdrop-filter - Bottom sheet surface backdrop filter.
|
||||
* @cssprop --ha-bottom-sheet-scrim-backdrop-filter - Bottom sheet scrim backdrop filter.
|
||||
* @cssprop --ha-bottom-sheet-scrim-color - Bottom sheet scrim color.
|
||||
*
|
||||
* @cssprop --ha-dialog-surface-background - Bottom sheet background color fallback.
|
||||
* @cssprop --ha-dialog-surface-backdrop-filter - Bottom sheet surface backdrop filter fallback.
|
||||
* @cssprop --ha-dialog-scrim-backdrop-filter - Bottom sheet scrim backdrop filter fallback.
|
||||
* @cssprop --dialog-backdrop-filter - Bottom sheet scrim backdrop filter legacy fallback.
|
||||
* @cssprop --mdc-dialog-scrim-color - Bottom sheet scrim color legacy fallback.
|
||||
*/
|
||||
@customElement("ha-bottom-sheet")
|
||||
export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -31,6 +66,8 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@state() private _drawerOpen = false;
|
||||
|
||||
@state() private _sliderInteractionActive = false;
|
||||
|
||||
@query("#drawer") private _drawer!: HTMLElement;
|
||||
|
||||
@query("#body") private _bodyElement!: HTMLDivElement;
|
||||
@@ -78,26 +115,58 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
fireEvent(this, "after-show");
|
||||
};
|
||||
|
||||
private _handleAfterHide = () => {
|
||||
this.open = false;
|
||||
this._drawerOpen = false;
|
||||
fireEvent(this, "closed");
|
||||
private _handleSliderInteractionStart = () => {
|
||||
this._sliderInteractionActive = true;
|
||||
};
|
||||
|
||||
private _handleSliderInteractionStop = () => {
|
||||
this._sliderInteractionActive = false;
|
||||
};
|
||||
|
||||
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
|
||||
if (this._sliderInteractionActive) {
|
||||
this._drawerOpen = true;
|
||||
this.open = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.eventPhase === Event.AT_TARGET) {
|
||||
this.open = false;
|
||||
this._drawerOpen = false;
|
||||
fireEvent(this, "closed");
|
||||
}
|
||||
};
|
||||
|
||||
private _handleHide = (ev: CustomEvent<{ source: Element }>) => {
|
||||
if (
|
||||
this.preventScrimClose &&
|
||||
this._escapePressed &&
|
||||
ev.detail.source === (ev.target as WaDrawer).drawer
|
||||
) {
|
||||
// Ignore bubbled wa-hide events from nested drawers (e.g., picker bottom sheet)
|
||||
if (ev.eventPhase !== Event.AT_TARGET) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceIsDrawer = ev.detail.source === (ev.target as WaDrawer).drawer;
|
||||
|
||||
if (this._sliderInteractionActive) {
|
||||
ev.preventDefault();
|
||||
this._drawerOpen = true;
|
||||
this.open = true;
|
||||
this._escapePressed = false;
|
||||
return;
|
||||
}
|
||||
if (this.preventScrimClose && this._escapePressed && sourceIsDrawer) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
this._escapePressed = false;
|
||||
};
|
||||
|
||||
private _handleKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape") {
|
||||
this._escapePressed = true;
|
||||
if (this.preventScrimClose) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
ev.stopPropagation();
|
||||
(ev.currentTarget as WaDrawer).open = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,6 +185,24 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener(
|
||||
"slider-interaction-start",
|
||||
this._handleSliderInteractionStart,
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
this.addEventListener(
|
||||
"slider-interaction-stop",
|
||||
this._handleSliderInteractionStop,
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("open")) {
|
||||
@@ -141,6 +228,9 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
without-header
|
||||
@touchstart=${this._handleTouchStart}
|
||||
>
|
||||
<div class="handle-wrapper" aria-hidden="true">
|
||||
<div class="handle"></div>
|
||||
</div>
|
||||
<slot name="header"></slot>
|
||||
<div class="content-wrapper">
|
||||
<div id="body" class="body ha-scrollbar">
|
||||
@@ -158,17 +248,33 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any element inside drawer in the composed path has scrollTop > 0
|
||||
for (const path of ev.composedPath()) {
|
||||
const el = path as HTMLElement;
|
||||
if (el === this._drawer) {
|
||||
const path = ev.composedPath();
|
||||
|
||||
for (const target of path) {
|
||||
if (target === this._drawer) {
|
||||
break;
|
||||
}
|
||||
if (el.scrollTop > 0) {
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
// Check if any element inside drawer in the composed path has scrollTop > 0 (list)
|
||||
target.scrollTop > 0 ||
|
||||
// Check if the element is a swipe locked component or has a swipe locked class
|
||||
SWIPE_LOCKED_COMPONENTS.has(target.localName) ||
|
||||
Array.from(target.classList).some((cls) =>
|
||||
SWIPE_LOCKED_CLASSES.has(cls)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop propagation so parent bottom sheets don't also start tracking
|
||||
// this gesture (same pattern as _handleKeyDown for Escape)
|
||||
ev.stopPropagation();
|
||||
this._startResizing(ev.touches[0].clientY);
|
||||
};
|
||||
|
||||
@@ -264,6 +370,20 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener(
|
||||
"slider-interaction-start",
|
||||
this._handleSliderInteractionStart,
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
this.removeEventListener(
|
||||
"slider-interaction-stop",
|
||||
this._handleSliderInteractionStop,
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
this._unregisterResizeHandlers();
|
||||
this._isDragging = false;
|
||||
}
|
||||
@@ -286,9 +406,42 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
transform: var(--dialog-transform);
|
||||
transition: var(--dialog-transition);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
wa-drawer {
|
||||
--wa-color-surface-raised: transparent;
|
||||
--spacing: 0;
|
||||
--size: var(--ha-bottom-sheet-height, auto);
|
||||
--show-duration: 1ms;
|
||||
--hide-duration: 1ms;
|
||||
}
|
||||
wa-drawer::part(dialog) {
|
||||
transition: 1ms;
|
||||
}
|
||||
}
|
||||
wa-drawer::part(dialog)::backdrop {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-scrim-backdrop-filter,
|
||||
var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
)
|
||||
);
|
||||
background-color: var(
|
||||
--ha-bottom-sheet-scrim-color,
|
||||
var(--mdc-dialog-scrim-color, none)
|
||||
);
|
||||
}
|
||||
wa-drawer::part(body) {
|
||||
max-width: var(--ha-bottom-sheet-max-width);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-top-left-radius: var(
|
||||
--ha-bottom-sheet-border-radius,
|
||||
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
|
||||
@@ -299,7 +452,18 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
);
|
||||
background-color: var(
|
||||
--ha-bottom-sheet-surface-background,
|
||||
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
|
||||
var(
|
||||
--ha-dialog-surface-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default))
|
||||
)
|
||||
);
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-bottom-sheet-surface-backdrop-filter,
|
||||
var(--ha-dialog-surface-backdrop-filter, none)
|
||||
);
|
||||
padding: var(
|
||||
--ha-bottom-sheet-padding,
|
||||
@@ -311,6 +475,35 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
:host([prevent-scrim-close]) .handle-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.handle-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
padding-bottom: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.handle-wrapper .handle {
|
||||
height: 16px;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.handle-wrapper .handle::after {
|
||||
content: "";
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
height: 4px;
|
||||
background: var(--ha-bottom-sheet-handle-color, var(--divider-color));
|
||||
width: 40px;
|
||||
}
|
||||
.content-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -360,6 +553,18 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"slider-interaction-start": undefined;
|
||||
"slider-interaction-stop": undefined;
|
||||
}
|
||||
interface HTMLElementEventMap {
|
||||
"slider-interaction-start": HASSDomEvent<
|
||||
HASSDomEvents["slider-interaction-start"]
|
||||
>;
|
||||
"slider-interaction-stop": HASSDomEvent<
|
||||
HASSDomEvents["slider-interaction-stop"]
|
||||
>;
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-bottom-sheet": HaBottomSheet;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class HaButton extends Button {
|
||||
Button.styles,
|
||||
css`
|
||||
:host {
|
||||
--wa-form-control-padding-inline: 16px;
|
||||
--wa-form-control-padding-inline: var(--ha-space-4);
|
||||
--wa-font-weight-action: var(--ha-font-weight-medium);
|
||||
--wa-form-control-border-radius: var(
|
||||
--ha-button-border-radius,
|
||||
@@ -68,7 +68,7 @@ export class HaButton extends Button {
|
||||
var(--button-height, 32px)
|
||||
);
|
||||
font-size: var(--wa-font-size-s, var(--ha-font-size-m));
|
||||
--wa-form-control-padding-inline: 12px;
|
||||
--wa-form-control-padding-inline: var(--ha-space-3);
|
||||
}
|
||||
|
||||
:host([variant="brand"]) {
|
||||
@@ -84,6 +84,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-primary-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-primary-quiet-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="neutral"]) {
|
||||
@@ -99,6 +102,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-neutral-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-neutral-normal-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="success"]) {
|
||||
@@ -114,6 +120,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-success-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-success-quiet-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="warning"]) {
|
||||
@@ -129,6 +138,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-warning-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-warning-quiet-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="danger"]) {
|
||||
@@ -144,6 +156,9 @@ export class HaButton extends Button {
|
||||
--button-color-fill-loud-hover: var(
|
||||
--ha-color-fill-danger-loud-hover
|
||||
);
|
||||
--button-color-fill-quiet-active: var(
|
||||
--ha-color-fill-danger-quiet-active
|
||||
);
|
||||
}
|
||||
|
||||
:host([appearance~="plain"]) .button {
|
||||
@@ -187,6 +202,10 @@ export class HaButton extends Button {
|
||||
background-color: var(--ha-color-fill-disabled-normal-resting);
|
||||
color: var(--ha-color-on-disabled-normal);
|
||||
}
|
||||
:host([appearance~="plain"])
|
||||
.button:not(.disabled):not(.loading):active {
|
||||
background-color: var(--button-color-fill-quiet-active);
|
||||
}
|
||||
|
||||
:host([appearance~="accent"]) .button {
|
||||
background-color: var(
|
||||
@@ -212,21 +231,21 @@ export class HaButton extends Button {
|
||||
}
|
||||
|
||||
slot[name="start"]::slotted(*) {
|
||||
margin-inline-end: 4px;
|
||||
margin-inline-end: var(--ha-space-1);
|
||||
}
|
||||
slot[name="end"]::slotted(*) {
|
||||
margin-inline-start: 4px;
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.button.has-start {
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-start: var(--ha-space-2);
|
||||
}
|
||||
.button.has-end {
|
||||
padding-inline-end: 8px;
|
||||
padding-inline-end: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
overflow: var(--ha-button-label-overflow, hidden);
|
||||
text-overflow: ellipsis;
|
||||
padding: var(--ha-space-1) 0;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { STATE_RUNNING } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
@@ -58,12 +59,22 @@ export class HaCameraStream extends LitElement {
|
||||
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
|
||||
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
if (
|
||||
const entityChanged =
|
||||
changedProps.has("stateObj") &&
|
||||
this.stateObj &&
|
||||
(changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !==
|
||||
this.stateObj.entity_id
|
||||
) {
|
||||
this.stateObj.entity_id;
|
||||
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
const backendStarted =
|
||||
changedProps.has("hass") &&
|
||||
this.hass &&
|
||||
this.stateObj &&
|
||||
oldHass &&
|
||||
this.hass.config.state === STATE_RUNNING &&
|
||||
oldHass.config?.state !== STATE_RUNNING;
|
||||
|
||||
if (entityChanged || backendStarted) {
|
||||
this._getCapabilities();
|
||||
this._getPosterUrl();
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
@property({ type: Boolean, attribute: "disable-fullscreen" })
|
||||
public disableFullscreen = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "in-dialog" })
|
||||
public inDialog = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "has-toolbar" })
|
||||
public hasToolbar = true;
|
||||
|
||||
@@ -132,6 +135,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.toggle("in-dialog", this.inDialog);
|
||||
// Force update on reconnection so editor is recreated
|
||||
if (this.hasUpdated) {
|
||||
this.requestUpdate();
|
||||
@@ -150,6 +154,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
fireEvent(this, "dialog-set-fullscreen", false);
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("keydown", stopPropagation);
|
||||
this.removeEventListener("keydown", this._handleKeyDown);
|
||||
@@ -216,6 +221,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
if (changedProps.has("error")) {
|
||||
this.classList.toggle("error-state", this.error);
|
||||
}
|
||||
if (changedProps.has("inDialog")) {
|
||||
this.classList.toggle("in-dialog", this.inDialog);
|
||||
}
|
||||
if (changedProps.has("_isFullscreen")) {
|
||||
this.classList.toggle("fullscreen", this._isFullscreen);
|
||||
this._updateToolbarButtons();
|
||||
@@ -434,10 +442,19 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
private _updateFullscreenState(
|
||||
fullscreen: boolean = this._isFullscreen
|
||||
): boolean {
|
||||
const previousFullscreen = this._isFullscreen;
|
||||
|
||||
this.classList.toggle("in-dialog", this.inDialog);
|
||||
|
||||
// Update the current fullscreen state based on selected value. If fullscreen
|
||||
// is disabled, or we have no toolbar, ensure we are not in fullscreen mode.
|
||||
this._isFullscreen =
|
||||
fullscreen && !this.disableFullscreen && this.hasToolbar;
|
||||
|
||||
if (previousFullscreen !== this._isFullscreen) {
|
||||
fireEvent(this, "dialog-set-fullscreen", this._isFullscreen);
|
||||
}
|
||||
|
||||
// Return whether successfully in requested state
|
||||
return this._isFullscreen === fullscreen;
|
||||
}
|
||||
@@ -846,10 +863,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
:host(.fullscreen) {
|
||||
position: fixed !important;
|
||||
top: calc(var(--header-height, 56px) + 8px) !important;
|
||||
left: 8px !important;
|
||||
right: 8px !important;
|
||||
bottom: 8px !important;
|
||||
top: calc(var(--header-height, 56px) + var(--ha-space-2)) !important;
|
||||
left: var(--ha-space-2) !important;
|
||||
right: var(--ha-space-2) !important;
|
||||
bottom: var(--ha-space-2) !important;
|
||||
z-index: 6;
|
||||
border-radius: var(--ha-border-radius-lg) !important;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
|
||||
@@ -867,6 +884,17 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
:host(.in-dialog.fullscreen) {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:host(.hasToolbar) .cm-editor {
|
||||
padding-top: var(--code-editor-toolbar-height);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export class HaControlButton extends LitElement {
|
||||
--control-button-background-color: var(--disabled-color);
|
||||
--control-button-background-opacity: 0.2;
|
||||
--control-button-border-radius: var(--ha-border-radius-md);
|
||||
--control-button-font-weight: var(--ha-font-weight-medium);
|
||||
--control-button-padding: 8px;
|
||||
--mdc-icon-size: 20px;
|
||||
--ha-ripple-color: var(--secondary-text-color);
|
||||
@@ -59,7 +60,7 @@ export class HaControlButton extends LitElement {
|
||||
box-sizing: border-box;
|
||||
line-height: inherit;
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--control-button-font-weight);
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
background: none;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { mdiMenuDown } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-attribute-icon";
|
||||
import "./ha-dropdown";
|
||||
import "./ha-dropdown-item";
|
||||
import "./ha-icon";
|
||||
@@ -16,17 +14,10 @@ export interface SelectOption {
|
||||
value: string;
|
||||
iconPath?: string;
|
||||
icon?: string;
|
||||
attributeIcon?: {
|
||||
stateObj: HassEntity;
|
||||
attribute: string;
|
||||
attributeValue?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@customElement("ha-control-select-menu")
|
||||
export class HaControlSelectMenu extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, attribute: "show-arrow" })
|
||||
public showArrow = false;
|
||||
|
||||
@@ -47,6 +38,9 @@ export class HaControlSelectMenu extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public options: SelectOption[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
public renderIcon?: (value: string) => TemplateResult<1> | typeof nothing;
|
||||
|
||||
@query("button") private _triggerButton!: HTMLButtonElement;
|
||||
|
||||
public override render() {
|
||||
@@ -94,14 +88,8 @@ export class HaControlSelectMenu extends LitElement {
|
||||
? html`<ha-svg-icon slot="icon" .path=${option.iconPath}></ha-svg-icon>`
|
||||
: option.icon
|
||||
? html`<ha-icon slot="icon" .icon=${option.icon}></ha-icon>`
|
||||
: option.attributeIcon
|
||||
? html`<ha-attribute-icon
|
||||
slot="icon"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${option.attributeIcon.stateObj}
|
||||
.attribute=${option.attributeIcon.attribute}
|
||||
.attributeValue=${option.attributeIcon.attributeValue}
|
||||
></ha-attribute-icon>`
|
||||
: this.renderIcon
|
||||
? html`<span slot="icon">${this.renderIcon(option.value)}</span>`
|
||||
: nothing}
|
||||
${option.label}</ha-dropdown-item
|
||||
>`;
|
||||
@@ -119,24 +107,20 @@ export class HaControlSelectMenu extends LitElement {
|
||||
}
|
||||
|
||||
private _renderIcon() {
|
||||
const { iconPath, icon, attributeIcon } =
|
||||
this.getValueObject(this.options, this.value) ?? {};
|
||||
const value = this.getValueObject(this.options, this.value);
|
||||
const defaultIcon = this.querySelector("[slot='icon']");
|
||||
|
||||
return html`
|
||||
<div class="icon">
|
||||
${iconPath
|
||||
? html`<ha-svg-icon slot="icon" .path=${iconPath}></ha-svg-icon>`
|
||||
: icon
|
||||
? html`<ha-icon slot="icon" .icon=${icon}></ha-icon>`
|
||||
: attributeIcon
|
||||
? html`<ha-attribute-icon
|
||||
slot="icon"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${attributeIcon.stateObj}
|
||||
.attribute=${attributeIcon.attribute}
|
||||
.attributeValue=${attributeIcon.attributeValue}
|
||||
></ha-attribute-icon>`
|
||||
${value?.iconPath
|
||||
? html`<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${value.iconPath}
|
||||
></ha-svg-icon>`
|
||||
: value?.icon
|
||||
? html`<ha-icon slot="icon" .icon=${value.icon}></ha-icon>`
|
||||
: this.renderIcon && this.value
|
||||
? this.renderIcon(this.value)
|
||||
: defaultIcon
|
||||
? html`<slot name="icon"></slot>`
|
||||
: nothing}
|
||||
@@ -172,12 +156,12 @@ export class HaControlSelectMenu extends LitElement {
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: 1.4;
|
||||
width: auto;
|
||||
color: var(--primary-text-color);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.select-anchor {
|
||||
border: none;
|
||||
text-align: left;
|
||||
color: var(--primary-text-color);
|
||||
height: var(--control-select-menu-height);
|
||||
padding: var(--control-select-menu-padding);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -95,7 +95,7 @@ export class HaCopyTextfield extends LitElement {
|
||||
right: 8px;
|
||||
inset-inline-start: initial;
|
||||
inset-inline-end: 8px;
|
||||
--mdc-icon-button-size: 40px;
|
||||
--ha-icon-button-size: 40px;
|
||||
--mdc-icon-size: 20px;
|
||||
color: var(--secondary-text-color);
|
||||
direction: var(--direction);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
|
||||
import { formatDateNumeric } from "../common/datetime/format_date";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -9,6 +9,7 @@ import { TimeZone } from "../data/translation";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
|
||||
const loadDatePickerDialog = () => import("./ha-dialog-date-picker");
|
||||
|
||||
@@ -52,6 +53,12 @@ export class HaDateInput extends LitElement {
|
||||
|
||||
@property({ attribute: "can-clear", type: Boolean }) public canClear = false;
|
||||
|
||||
@query("ha-textfield", true) private _input?: HaTextField;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<ha-textfield
|
||||
.label=${this.label}
|
||||
|
||||
@@ -93,6 +93,8 @@ export class HaDateRangePicker extends LitElement {
|
||||
| "center"
|
||||
| "inline";
|
||||
|
||||
@state() private _calcedVerticalOpeningDirection?: "up" | "down";
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
(!this.hasUpdated && this.ranges === undefined) ||
|
||||
@@ -134,7 +136,9 @@ export class HaDateRangePicker extends LitElement {
|
||||
opening-direction=${ifDefined(
|
||||
this.openingDirection || this._calcedOpeningDirection
|
||||
)}
|
||||
opens-vertical=${ifDefined(this.verticalOpeningDirection)}
|
||||
opens-vertical=${ifDefined(
|
||||
this.verticalOpeningDirection || this._calcedVerticalOpeningDirection
|
||||
)}
|
||||
first-day=${firstWeekdayIndex(this.hass.locale)}
|
||||
language=${this.hass.locale.language}
|
||||
@change=${this._handleChange}
|
||||
@@ -328,17 +332,24 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
private _handleClick() {
|
||||
// calculate opening direction if not set
|
||||
if (!this._dateRangePicker.open && !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";
|
||||
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";
|
||||
}
|
||||
this._calcedOpeningDirection = opens;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+117
-37
@@ -10,7 +10,9 @@ import {
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
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 { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -20,6 +22,8 @@ import "./ha-icon-button";
|
||||
|
||||
export type DialogWidth = "small" | "medium" | "large" | "full";
|
||||
|
||||
type DialogHideEvent = CustomEvent<{ source?: Element }>;
|
||||
|
||||
/**
|
||||
* Home Assistant dialog component
|
||||
*
|
||||
@@ -50,7 +54,12 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
|
||||
* @cssprop --ha-dialog-show-duration - Show animation duration.
|
||||
* @cssprop --ha-dialog-hide-duration - Hide animation duration.
|
||||
* @cssprop --ha-dialog-surface-background - Dialog background color.
|
||||
* @cssprop --ha-dialog-surface-backdrop-filter - Dialog backdrop filter.
|
||||
* @cssprop --dialog-box-shadow - Dialog box shadow.
|
||||
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface.
|
||||
* @cssprop --ha-dialog-scrim-backdrop-filter - Dialog scrim backdrop filter.
|
||||
* @cssprop --dialog-backdrop-filter - Dialog scrim backdrop filter (legacy).
|
||||
* @cssprop --mdc-dialog-scrim-color - Dialog scrim color (legacy).
|
||||
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
|
||||
*
|
||||
* @attr {boolean} open - Controls the dialog open state.
|
||||
@@ -120,6 +129,14 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _escapePressed = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener(
|
||||
"dialog-set-fullscreen",
|
||||
this._handleFullscreenChanged as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
return this.bodyContainer;
|
||||
}
|
||||
@@ -187,7 +204,10 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleShow = async () => {
|
||||
private _handleShow = async (ev: Event) => {
|
||||
if (ev.eventPhase !== Event.AT_TARGET) {
|
||||
return;
|
||||
}
|
||||
this._open = true;
|
||||
fireEvent(this, "opened");
|
||||
|
||||
@@ -213,22 +233,46 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
});
|
||||
};
|
||||
|
||||
private _handleAfterShow = () => {
|
||||
private _handleAfterShow = (ev: Event) => {
|
||||
if (ev.eventPhase !== Event.AT_TARGET) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "after-show");
|
||||
};
|
||||
|
||||
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
|
||||
private _handleAfterHide = (ev: DialogHideEvent) => {
|
||||
if (ev.eventPhase === Event.AT_TARGET) {
|
||||
this._open = false;
|
||||
this._setFullscreen(false);
|
||||
fireEvent(this, "closed");
|
||||
}
|
||||
};
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this.removeEventListener(
|
||||
"dialog-set-fullscreen",
|
||||
this._handleFullscreenChanged as EventListener
|
||||
);
|
||||
this._setFullscreen(false);
|
||||
super.disconnectedCallback();
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _handleFullscreenChanged(ev: HASSDomEvent<boolean>): void {
|
||||
if (!this._open) {
|
||||
this._setFullscreen(ev.detail);
|
||||
return;
|
||||
}
|
||||
|
||||
withViewTransition(() => {
|
||||
this._setFullscreen(ev.detail);
|
||||
});
|
||||
}
|
||||
|
||||
private _setFullscreen(fullscreen: boolean): void {
|
||||
this.toggleAttribute("fullscreen", fullscreen);
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _handleBodyScroll(ev: Event) {
|
||||
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
|
||||
@@ -237,17 +281,21 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Escape") {
|
||||
this._escapePressed = true;
|
||||
if (this.preventScrimClose) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
ev.stopPropagation();
|
||||
(ev.currentTarget as WaDialog).open = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleHide(ev: CustomEvent<{ source: Element }>) {
|
||||
if (
|
||||
this.preventScrimClose &&
|
||||
this._escapePressed &&
|
||||
ev.detail.source === (ev.target as WaDialog).dialog
|
||||
) {
|
||||
private _handleHide(ev: DialogHideEvent) {
|
||||
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
|
||||
|
||||
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
this._escapePressed = false;
|
||||
}
|
||||
|
||||
@@ -265,10 +313,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
--spacing: var(--dialog-content-padding, var(--ha-space-6));
|
||||
--show-duration: var(--ha-dialog-show-duration, 200ms);
|
||||
--hide-duration: var(--ha-dialog-hide-duration, 200ms);
|
||||
--ha-dialog-surface-background: var(
|
||||
--card-background-color,
|
||||
var(--ha-color-surface-default)
|
||||
);
|
||||
--wa-color-surface-raised: var(
|
||||
--ha-dialog-surface-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default))
|
||||
@@ -281,8 +325,8 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
wa-dialog {
|
||||
--show-duration: 1ms;
|
||||
--hide-duration: 1ms;
|
||||
--show-duration: 0ms;
|
||||
--hide-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,11 +338,34 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
--width: min(var(--ha-dialog-width-lg, 1024px), var(--full-width));
|
||||
}
|
||||
|
||||
:host([width="full"]) wa-dialog {
|
||||
:host([width="full"]) wa-dialog,
|
||||
:host([fullscreen]) wa-dialog {
|
||||
--width: var(--full-width);
|
||||
}
|
||||
|
||||
:host([fullscreen]) wa-dialog::part(dialog) {
|
||||
min-height: var(--safe-height);
|
||||
max-height: var(--safe-height);
|
||||
margin-top: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
:host([fullscreen]) .content-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host([fullscreen]) .body {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-surface-backdrop-filter,
|
||||
none
|
||||
);
|
||||
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
|
||||
box-shadow: var(--dialog-box-shadow, var(--wa-shadow-l));
|
||||
color: var(--primary-text-color);
|
||||
min-width: var(--width, var(--full-width));
|
||||
max-width: var(--width, var(--full-width));
|
||||
@@ -328,32 +395,44 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog)::backdrop {
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
);
|
||||
backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
);
|
||||
background-color: var(--mdc-dialog-scrim-color, none);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
:host([type="standard"]) {
|
||||
--ha-dialog-border-radius: 0;
|
||||
}
|
||||
|
||||
wa-dialog {
|
||||
/* Make the container fill the whole screen width and not the safe width */
|
||||
--full-width: var(--ha-dialog-width-full, 100vw);
|
||||
--width: var(--full-width);
|
||||
}
|
||||
:host([type="standard"]) wa-dialog {
|
||||
/* Make the container fill the whole screen width and not the safe width */
|
||||
--full-width: var(--ha-dialog-width-full, 100vw);
|
||||
--width: var(--full-width);
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
/* Make the dialog fill the whole screen height and not the safe height */
|
||||
min-height: var(--ha-dialog-min-height, 100vh);
|
||||
min-height: var(--ha-dialog-min-height, 100dvh);
|
||||
max-height: var(--ha-dialog-max-height, 100vh);
|
||||
max-height: var(--ha-dialog-max-height, 100dvh);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
/* Use safe area as padding instead of the container size */
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
/* Reset the transform to center the dialog */
|
||||
transform: none;
|
||||
}
|
||||
:host([type="standard"]) wa-dialog::part(dialog) {
|
||||
/* Make the dialog fill the whole screen height and not the safe height */
|
||||
min-height: var(--ha-dialog-min-height, 100vh);
|
||||
min-height: var(--ha-dialog-min-height, 100dvh);
|
||||
max-height: var(--ha-dialog-max-height, 100vh);
|
||||
max-height: var(--ha-dialog-max-height, 100dvh);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
/* Use safe area as padding instead of the container size */
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
/* Reset the transform to center the dialog */
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +519,7 @@ declare global {
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"dialog-set-fullscreen": boolean;
|
||||
opened: undefined;
|
||||
"after-show": undefined;
|
||||
closed: undefined;
|
||||
|
||||
@@ -186,9 +186,11 @@ export class HaDrawer extends DrawerBase {
|
||||
padding-inline-start var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Use 1ms instead of "none" so the transitionend event still fires.
|
||||
The MDC drawer foundation relies on it to complete the close cycle. */
|
||||
.mdc-drawer,
|
||||
.mdc-drawer-app-content {
|
||||
transition: none;
|
||||
transition: 1ms;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type WaButton from "@home-assistant/webawesome/dist/components/button/button";
|
||||
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { HaDropdownItem } from "./ha-dropdown-item";
|
||||
import type { HaIconButton } from "./ha-icon-button";
|
||||
|
||||
/**
|
||||
* Event type for the ha-dropdown component when an item is selected.
|
||||
@@ -29,16 +29,25 @@ export class HaDropdown extends Dropdown {
|
||||
|
||||
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
|
||||
|
||||
public get anchorElement(): HTMLButtonElement | WaButton | undefined {
|
||||
public get anchorElement(): HTMLButtonElement | HaIconButton | undefined {
|
||||
// @ts-ignore Allow to set an anchor element on popup
|
||||
return this.popup?.anchor as HTMLButtonElement | WaButton | undefined;
|
||||
return this.popup?.anchor as HTMLButtonElement | HaIconButton | undefined;
|
||||
}
|
||||
|
||||
public set anchorElement(element: HTMLButtonElement | WaButton | undefined) {
|
||||
public set anchorElement(
|
||||
element: HTMLButtonElement | HaIconButton | undefined
|
||||
) {
|
||||
// @ts-ignore Allow to get the current anchor element from popup
|
||||
if (!this.popup) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (this.popup.anchor && this.popup.anchor.localName === "ha-icon-button") {
|
||||
// @ts-ignore
|
||||
(this.popup.anchor as HaIconButton).selected = false;
|
||||
}
|
||||
|
||||
// @ts-ignore Allow to get the current anchor element from popup
|
||||
this.popup.anchor = element;
|
||||
}
|
||||
@@ -46,7 +55,7 @@ export class HaDropdown extends Dropdown {
|
||||
/** Get the slotted trigger button, a <wa-button> or <button> element */
|
||||
// @ts-ignore Override parent method to be able to use alternative anchor
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
private override getTrigger(): HTMLButtonElement | WaButton | null {
|
||||
private override getTrigger(): HTMLButtonElement | HaIconButton | null {
|
||||
if (this.anchorElement) {
|
||||
return this.anchorElement;
|
||||
}
|
||||
@@ -54,6 +63,28 @@ export class HaDropdown extends Dropdown {
|
||||
return super.getTrigger();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
private override async showMenu() {
|
||||
// @ts-ignore
|
||||
await super.showMenu();
|
||||
const triggerElement = this.getTrigger();
|
||||
if (triggerElement && triggerElement.localName === "ha-icon-button") {
|
||||
(triggerElement as HaIconButton).selected = true;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
private override async hideMenu() {
|
||||
const triggerElement = this.getTrigger();
|
||||
if (triggerElement && triggerElement.localName === "ha-icon-button") {
|
||||
(triggerElement as HaIconButton).selected = false;
|
||||
}
|
||||
// @ts-ignore
|
||||
await super.hideMenu();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
Dropdown.styles,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { mdiMinusThick, mdiPlusThick } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-base-time-input";
|
||||
import type { TimeChangedEvent } from "./ha-base-time-input";
|
||||
import "./ha-button-toggle-group";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import "./ha-base-time-input";
|
||||
import type { HaBaseTimeInput, TimeChangedEvent } from "./ha-base-time-input";
|
||||
import "./ha-button-toggle-group";
|
||||
|
||||
export interface HaDurationData {
|
||||
days?: number;
|
||||
@@ -19,7 +19,7 @@ export interface HaDurationData {
|
||||
const FIELDS = ["milliseconds", "seconds", "minutes", "hours", "days"];
|
||||
|
||||
@customElement("ha-duration-input")
|
||||
class HaDurationInput extends LitElement {
|
||||
export class HaDurationInput extends LitElement {
|
||||
@property({ attribute: false }) public data?: HaDurationData;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -37,10 +37,24 @@ class HaDurationInput extends LitElement {
|
||||
@property({ attribute: "allow-negative", type: Boolean })
|
||||
public allowNegative = false;
|
||||
|
||||
@property({ attribute: "enable-second", type: Boolean })
|
||||
public enableSecond = true;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-base-time-input", true) private _input?: HaBaseTimeInput;
|
||||
|
||||
private _toggleNegative = false;
|
||||
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="row">
|
||||
@@ -65,7 +79,7 @@ class HaDurationInput extends LitElement {
|
||||
.autoValidate=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
errorMessage="Required"
|
||||
enable-second
|
||||
.enableSecond=${this.enableSecond}
|
||||
.enableMillisecond=${this.enableMillisecond}
|
||||
.enableDay=${this.enableDay}
|
||||
format="24"
|
||||
@@ -162,9 +176,9 @@ class HaDurationInput extends LitElement {
|
||||
if (value) {
|
||||
value.hours ||= 0;
|
||||
value.minutes ||= 0;
|
||||
value.seconds ||= 0;
|
||||
|
||||
if ("days" in value) value.days ||= 0;
|
||||
if ("seconds" in value) value.seconds ||= 0;
|
||||
if ("milliseconds" in value) value.milliseconds ||= 0;
|
||||
|
||||
if (this.allowNegative) {
|
||||
@@ -183,8 +197,11 @@ class HaDurationInput extends LitElement {
|
||||
value.milliseconds %= 1000;
|
||||
}
|
||||
|
||||
if (value.seconds > 59) {
|
||||
value.minutes += Math.floor(value.seconds / 60);
|
||||
if (!this.enableSecond && !value.seconds) {
|
||||
// @ts-ignore
|
||||
delete value.seconds;
|
||||
} else if (this.enableSecond && value.seconds > 59) {
|
||||
value.minutes = (value.minutes ?? 0) + Math.floor(value.seconds / 60);
|
||||
value.seconds %= 60;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { blankBeforePercent } from "../common/translations/blank_before_percent";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { bytesToString } from "../util/bytes-to-string";
|
||||
import "./ha-button";
|
||||
import "./ha-icon-button";
|
||||
import { blankBeforePercent } from "../common/translations/blank_before_percent";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { bytesToString } from "../util/bytes-to-string";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -317,7 +317,7 @@ export class HaFileUpload extends LitElement {
|
||||
}
|
||||
ha-button {
|
||||
--mdc-button-outline-color: var(--primary-color);
|
||||
--mdc-icon-button-size: 24px;
|
||||
--ha-icon-button-size: 24px;
|
||||
}
|
||||
mwc-linear-progress {
|
||||
width: 100%;
|
||||
|
||||
@@ -26,12 +26,12 @@ import { showCategoryRegistryDetailDialog } from "../panels/config/category/show
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "./ha-dropdown";
|
||||
import "./ha-dropdown-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon";
|
||||
import "./ha-list";
|
||||
import "./ha-list-item";
|
||||
import type { HaDropdownSelectEvent } from "./ha-dropdown";
|
||||
|
||||
@customElement("ha-filter-categories")
|
||||
export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
@@ -317,7 +317,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
--mdc-list-item-meta-size: auto;
|
||||
--mdc-list-side-padding-right: var(--ha-space-1);
|
||||
--mdc-list-side-padding-left: var(--ha-space-4);
|
||||
--mdc-icon-button-size: 36px;
|
||||
--ha-icon-button-size: 36px;
|
||||
}
|
||||
ha-list-item {
|
||||
--mdc-list-item-graphic-margin: var(--ha-space-4);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-form";
|
||||
import "../ha-expansion-panel";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
HaFormDataContainer,
|
||||
HaFormElement,
|
||||
@@ -35,6 +36,12 @@ export class HaFormExpandable extends LitElement implements HaFormElement {
|
||||
key: string
|
||||
) => string;
|
||||
|
||||
@query("ha-form", true) private _form?: HaForm;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._form?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private _renderDescription() {
|
||||
const description = this.computeHelper?.(this.schema);
|
||||
return description ? html`<p>${description}</p>` : nothing;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HaTextField } from "../ha-textfield";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import "../ha-textfield";
|
||||
import type { HaTextField } from "../ha-textfield";
|
||||
import type {
|
||||
HaFormElement,
|
||||
HaFormFloatData,
|
||||
HaFormFloatSchema,
|
||||
} from "./types";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
|
||||
@customElement("ha-form-float")
|
||||
export class HaFormFloat extends LitElement implements HaFormElement {
|
||||
@@ -25,12 +25,15 @@ export class HaFormFloat extends LitElement implements HaFormElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-textfield") private _input?: HaTextField;
|
||||
@query("ha-textfield", true) private _input?: HaTextField;
|
||||
|
||||
public focus() {
|
||||
if (this._input) {
|
||||
this._input.focus();
|
||||
}
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import "./ha-form";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
HaFormGridSchema,
|
||||
HaFormDataContainer,
|
||||
HaFormElement,
|
||||
HaFormGridSchema,
|
||||
HaFormSchema,
|
||||
} from "./types";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
@customElement("ha-form-grid")
|
||||
export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
@@ -33,9 +34,22 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
key: string
|
||||
) => string;
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
this.renderRoot.querySelector("ha-form")?.focus();
|
||||
@queryAll("ha-form", true) private _forms?: HaForm[];
|
||||
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
const forms = this._forms ?? [];
|
||||
let valid = true;
|
||||
for (const form of forms) {
|
||||
if (!form.reportValidity()) {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-slider";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
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 type {
|
||||
@@ -13,7 +14,6 @@ import type {
|
||||
HaFormIntegerData,
|
||||
HaFormIntegerSchema,
|
||||
} from "./types";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
|
||||
@customElement("ha-form-integer")
|
||||
export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
@@ -29,24 +29,39 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-textfield ha-slider") private _input?:
|
||||
@query("ha-textfield, ha-slider", true) private _input?:
|
||||
| HaTextField
|
||||
| HTMLInputElement;
|
||||
|
||||
private _lastValue?: HaFormIntegerData;
|
||||
|
||||
public focus() {
|
||||
if (this._input) {
|
||||
this._input.focus();
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
const showSlider = this._showSlider();
|
||||
if (showSlider && this.schema.required && isNaN(Number(this.data))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!showSlider) {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (
|
||||
private _showSlider(): boolean {
|
||||
return (
|
||||
this.schema.valueMin !== undefined &&
|
||||
this.schema.valueMax !== undefined &&
|
||||
this.schema.valueMax - this.schema.valueMin < 256
|
||||
) {
|
||||
);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this._showSlider()) {
|
||||
return html`
|
||||
<div>
|
||||
${this.label}
|
||||
|
||||
@@ -44,6 +44,13 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
this._dropdown?.focus();
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
if (!this.schema.required || (this.data && this.data.length > 0)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const options = Array.isArray(this.schema.options)
|
||||
? this.schema.options
|
||||
|
||||
@@ -8,16 +8,17 @@ import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-button";
|
||||
import "../ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../ha-dropdown";
|
||||
import "../ha-dropdown-item";
|
||||
import "../ha-svg-icon";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
HaFormDataContainer,
|
||||
HaFormElement,
|
||||
HaFormOptionalActionsSchema,
|
||||
HaFormSchema,
|
||||
} from "./types";
|
||||
import type { HaDropdownSelectEvent } from "../ha-dropdown";
|
||||
|
||||
const NO_ACTIONS = [];
|
||||
|
||||
@@ -53,6 +54,11 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
|
||||
this.renderRoot.querySelector("ha-form")?.focus();
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
const form = this.renderRoot.querySelector<HaForm>("ha-form");
|
||||
return form ? form.reportValidity() : true;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("data")) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationInput } from "../ha-duration-input";
|
||||
import type { HaFormElement, HaFormTimeData, HaFormTimeSchema } from "./types";
|
||||
|
||||
@customElement("ha-form-positive_time_period_dict")
|
||||
@@ -14,12 +15,15 @@ export class HaFormTimePeriod extends LitElement implements HaFormElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-time-input", true) private _input?: HTMLElement;
|
||||
@query("ha-duration-input", true) private _input?: HaDurationInput;
|
||||
|
||||
public focus() {
|
||||
if (this._input) {
|
||||
this._input.focus();
|
||||
}
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { SelectSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-selector/ha-selector-select";
|
||||
import type {
|
||||
HaFormElement,
|
||||
HaFormSelectData,
|
||||
HaFormSelectSchema,
|
||||
} from "./types";
|
||||
import type { SelectSelector } from "../../data/selector";
|
||||
import "../ha-selector/ha-selector-select";
|
||||
|
||||
@customElement("ha-form-select")
|
||||
export class HaFormSelect extends LitElement implements HaFormElement {
|
||||
@@ -41,6 +41,13 @@ export class HaFormSelect extends LitElement implements HaFormElement {
|
||||
})
|
||||
);
|
||||
|
||||
public reportValidity(): boolean {
|
||||
if (!this.schema.required || this.data) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-selector-select
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
} from "../../common/translations/localize";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-textfield";
|
||||
import type { HaTextField } from "../ha-textfield";
|
||||
@@ -11,10 +15,6 @@ import type {
|
||||
HaFormStringData,
|
||||
HaFormStringSchema,
|
||||
} from "./types";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
} from "../../common/translations/localize";
|
||||
|
||||
const MASKED_FIELDS = ["password", "secret", "token"];
|
||||
|
||||
@@ -37,12 +37,15 @@ export class HaFormString extends LitElement implements HaFormElement {
|
||||
|
||||
@state() protected unmaskedPassword = false;
|
||||
|
||||
@query("ha-textfield") private _input?: HaTextField;
|
||||
@query("ha-textfield", true) private _input?: HaTextField;
|
||||
|
||||
public focus(): void {
|
||||
if (this._input) {
|
||||
this._input.focus();
|
||||
}
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -148,7 +151,7 @@ export class HaFormString extends LitElement implements HaFormElement {
|
||||
right: 8px;
|
||||
inset-inline-start: initial;
|
||||
inset-inline-end: 8px;
|
||||
--mdc-icon-button-size: 40px;
|
||||
--ha-icon-button-size: 40px;
|
||||
--mdc-icon-size: 20px;
|
||||
color: var(--secondary-text-color);
|
||||
direction: var(--direction);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, ReactiveElement } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -24,7 +24,7 @@ const LOAD_ELEMENTS = {
|
||||
};
|
||||
|
||||
const getValue = (obj, item) =>
|
||||
obj ? (!item.name || item.flatten ? obj : obj[item.name]) : null;
|
||||
obj ? (!item.name || item.flatten ? obj : obj[item.name]) : undefined;
|
||||
|
||||
const getError = (obj, item) => (obj && item.name ? obj[item.name] : null);
|
||||
|
||||
@@ -76,22 +76,64 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
static shadowRootOptions: ShadowRootInit = {
|
||||
mode: "open",
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
const root = this.renderRoot.querySelector(".root");
|
||||
if (!root) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
for (const child of root.children) {
|
||||
if (child.tagName !== "HA-ALERT") {
|
||||
if (child instanceof ReactiveElement) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await child.updateComplete;
|
||||
}
|
||||
(child as HTMLElement).focus();
|
||||
break;
|
||||
|
||||
const elements = [...root.children].filter(
|
||||
(child) => child.localName !== "ha-alert"
|
||||
) as (HTMLElement & { reportValidity?: () => boolean })[];
|
||||
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
this.schema.forEach((item, index) => {
|
||||
const element = elements[index];
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
let elementValid = true;
|
||||
|
||||
if (
|
||||
"reportValidity" in element &&
|
||||
typeof element.reportValidity === "function"
|
||||
) {
|
||||
elementValid = element.reportValidity();
|
||||
} else if (
|
||||
item.required &&
|
||||
!(
|
||||
"type" in item && ["boolean", "constant"].includes(item.type ?? "")
|
||||
) &&
|
||||
!(
|
||||
"selector" in item &&
|
||||
("boolean" in item.selector || "constant" in item.selector)
|
||||
)
|
||||
) {
|
||||
const value = getValue(this.data, item);
|
||||
elementValid = value !== undefined && value !== null && value !== "";
|
||||
}
|
||||
|
||||
if (!elementValid) {
|
||||
isValid = false;
|
||||
if (!firstInvalidElement) {
|
||||
firstInvalidElement = element;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (firstInvalidElement) {
|
||||
firstInvalidElement.focus?.();
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
@@ -105,11 +147,6 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
}
|
||||
}
|
||||
|
||||
static shadowRootOptions: ShadowRootInit = {
|
||||
mode: "open",
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="root" part="root">
|
||||
|
||||
@@ -194,7 +194,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
.image=${this.image}
|
||||
.label=${label}
|
||||
.placeholder=${this.placeholder}
|
||||
.helper=${this.helper}
|
||||
.value=${this.value}
|
||||
.valueRenderer=${this.valueRenderer}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { mdiRestore } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
|
||||
import "./ha-icon-button";
|
||||
import { mdiRestore } from "@mdi/js";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { conditionalClamp } from "../common/number/clamp";
|
||||
import type { CardGridSize } from "../panels/lovelace/common/compute-card-grid-size";
|
||||
import { DEFAULT_GRID_SIZE } from "../panels/lovelace/common/compute-card-grid-size";
|
||||
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon-button";
|
||||
|
||||
@customElement("ha-grid-size-picker")
|
||||
export class HaGridSizeEditor extends LitElement {
|
||||
@@ -245,7 +245,7 @@ export class HaGridSizeEditor extends LitElement {
|
||||
}
|
||||
.reset {
|
||||
grid-area: reset;
|
||||
--mdc-icon-button-size: 36px;
|
||||
--ha-icon-button-size: 36px;
|
||||
}
|
||||
.preview {
|
||||
position: relative;
|
||||
|
||||
@@ -38,7 +38,7 @@ export class HaBadge extends LitElement {
|
||||
font-weight: var(--ha-heading-badge-font-weight, 400);
|
||||
line-height: var(--ha-heading-badge-line-height, 20px);
|
||||
letter-spacing: 0.1px;
|
||||
--mdc-icon-size: 14px;
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
::slotted([slot="icon"]) {
|
||||
--ha-icon-display: block;
|
||||
|
||||
@@ -14,6 +14,14 @@ export class HaIconButtonArrowPrev extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() href?: string;
|
||||
|
||||
@property() target?: "_blank" | "_parent" | "_self" | "_top";
|
||||
|
||||
@property() rel?: string;
|
||||
|
||||
@property() download?: string;
|
||||
|
||||
@state() private _icon =
|
||||
mainWindow.document.dir === "rtl" ? mdiArrowRight : mdiArrowLeft;
|
||||
|
||||
@@ -23,6 +31,10 @@ export class HaIconButtonArrowPrev extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label || this.hass?.localize("ui.common.back") || "Back"}
|
||||
.path=${this._icon}
|
||||
.href=${this.href}
|
||||
.target=${this.target}
|
||||
.rel=${this.rel}
|
||||
.download=${this.download}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ export class HaIconButtonNext extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() href?: string;
|
||||
|
||||
@property() target?: "_blank" | "_parent" | "_self" | "_top";
|
||||
|
||||
@property() rel?: string;
|
||||
|
||||
@property() download?: string;
|
||||
|
||||
@state() private _icon =
|
||||
mainWindow.document.dir === "rtl" ? mdiChevronLeft : mdiChevronRight;
|
||||
|
||||
@@ -23,6 +31,10 @@ export class HaIconButtonNext extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label || this.hass?.localize("ui.common.next") || "Next"}
|
||||
.path=${this._icon}
|
||||
.href=${this.href}
|
||||
.target=${this.target}
|
||||
.rel=${this.rel}
|
||||
.download=${this.download}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ export class HaIconButtonPrev extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() href?: string;
|
||||
|
||||
@property() target?: "_blank" | "_parent" | "_self" | "_top";
|
||||
|
||||
@property() rel?: string;
|
||||
|
||||
@property() download?: string;
|
||||
|
||||
@state() private _icon =
|
||||
mainWindow.document.dir === "rtl" ? mdiChevronRight : mdiChevronLeft;
|
||||
|
||||
@@ -23,6 +31,10 @@ export class HaIconButtonPrev extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label || this.hass?.localize("ui.common.back") || "Back"}
|
||||
.path=${this._icon}
|
||||
.href=${this.href}
|
||||
.target=${this.target}
|
||||
.rel=${this.rel}
|
||||
.download=${this.download}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { HaIconButton } from "./ha-icon-button";
|
||||
@@ -6,41 +7,51 @@ import { HaIconButton } from "./ha-icon-button";
|
||||
export class HaIconButtonToggle extends HaIconButton {
|
||||
@property({ type: Boolean, reflect: true }) selected = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
}
|
||||
mwc-icon-button {
|
||||
position: relative;
|
||||
transition: color 180ms ease-in-out;
|
||||
}
|
||||
mwc-icon-button::before {
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease-in-out;
|
||||
background-color: var(--primary-text-color);
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
:host([border-only]) mwc-icon-button::before {
|
||||
background-color: transparent;
|
||||
border: 2px solid var(--primary-text-color);
|
||||
}
|
||||
:host([selected]) mwc-icon-button {
|
||||
color: var(--primary-background-color);
|
||||
}
|
||||
:host([selected]:not([disabled])) mwc-icon-button::before {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
static styles: CSSResultGroup = [
|
||||
HaIconButton.styles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
}
|
||||
ha-button::part(base) {
|
||||
position: relative;
|
||||
transition: color 180ms ease-in-out;
|
||||
}
|
||||
ha-button::part(base)::before {
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease-in-out;
|
||||
background-color: var(--primary-text-color);
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
:host([border-only]) ha-button::part(base)::before {
|
||||
background-color: transparent;
|
||||
border: 2px solid var(--primary-text-color);
|
||||
}
|
||||
:host([selected]) ha-button::after {
|
||||
opacity: 0;
|
||||
}
|
||||
:host([selected]) ha-button::part(base) {
|
||||
color: var(--primary-background-color);
|
||||
background-color: unset;
|
||||
}
|
||||
:host([selected]:not([disabled])) ha-button::part(base)::before {
|
||||
opacity: 1;
|
||||
}
|
||||
::slotted(*) {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -109,7 +109,7 @@ export class HaIconButtonToolbar extends LitElement {
|
||||
|
||||
.icon-toolbar-button {
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-button-size: var(--icon-button-toolbar-button);
|
||||
--ha-icon-button-size: var(--icon-button-toolbar-button);
|
||||
--mdc-icon-size: var(--icon-button-toolbar-icon);
|
||||
/* Ensure button is clickable on iOS */
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import "@material/mwc-icon-button";
|
||||
import type { IconButton } from "@material/mwc-icon-button";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import "./ha-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-icon-button")
|
||||
@@ -19,15 +18,19 @@ export class HaIconButton extends LitElement {
|
||||
// These should always be set as properties, not attributes,
|
||||
// so that only the <button> element gets the attribute
|
||||
@property({ type: String, attribute: "aria-haspopup" })
|
||||
override ariaHasPopup!: IconButton["ariaHasPopup"];
|
||||
ariaHasPopup!: "false" | "true" | "menu" | "listbox" | "tree" | "grid";
|
||||
|
||||
@property({ attribute: "hide-title", type: Boolean }) hideTitle = false;
|
||||
|
||||
@query("mwc-icon-button", true) private _button?: IconButton;
|
||||
@property({ type: Boolean, reflect: true }) selected = false;
|
||||
|
||||
public override focus() {
|
||||
this._button?.focus();
|
||||
}
|
||||
@property() href?: string;
|
||||
|
||||
@property() target?: "_blank" | "_parent" | "_self" | "_top";
|
||||
|
||||
@property() rel?: string;
|
||||
|
||||
@property() download?: string;
|
||||
|
||||
static shadowRootOptions: ShadowRootInit = {
|
||||
mode: "open",
|
||||
@@ -36,30 +39,69 @@ export class HaIconButton extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<mwc-icon-button
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
variant="neutral"
|
||||
aria-label=${ifDefined(this.label)}
|
||||
title=${ifDefined(this.hideTitle ? undefined : this.label)}
|
||||
aria-haspopup=${ifDefined(this.ariaHasPopup)}
|
||||
.disabled=${this.disabled}
|
||||
.iconTag=${this.path ? "ha-svg-icon" : "span"}
|
||||
.href=${this.href}
|
||||
.target=${this.target}
|
||||
.rel=${this.rel}
|
||||
.download=${this.download}
|
||||
>
|
||||
${this.path
|
||||
? html`<ha-svg-icon .path=${this.path}></ha-svg-icon>`
|
||||
: html`<slot></slot>`}
|
||||
</mwc-icon-button>
|
||||
: html`<span><slot></slot></span>`}
|
||||
</ha-button>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
--ha-button-height: var(--ha-icon-button-size, 48px);
|
||||
}
|
||||
:host([disabled]) {
|
||||
ha-button {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
--wa-form-control-padding-inline: var(
|
||||
--ha-icon-button-padding-inline,
|
||||
--ha-space-2
|
||||
);
|
||||
--wa-color-on-normal: currentColor;
|
||||
--wa-color-fill-quiet: transparent;
|
||||
--ha-button-label-overflow: visible;
|
||||
}
|
||||
ha-button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
mwc-icon-button {
|
||||
--mdc-theme-on-primary: currentColor;
|
||||
--mdc-theme-text-disabled-on-light: var(--disabled-text-color);
|
||||
ha-button::part(base) {
|
||||
width: var(--wa-form-control-height);
|
||||
aspect-ratio: 1;
|
||||
outline-offset: -4px;
|
||||
}
|
||||
ha-button::part(label) {
|
||||
display: flex;
|
||||
}
|
||||
:host([selected]) ha-button::after {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:host(:hover:not([disabled])) ha-button::after {
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,6 @@ export class HaLanguagePicker extends LitElement {
|
||||
static styles = css`
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -84,13 +84,11 @@ export class HaMarkdown extends LitElement {
|
||||
ha-markdown-element > :is(ol, ul) {
|
||||
padding-inline-start: var(--markdown-list-indent, revert);
|
||||
}
|
||||
li {
|
||||
&:has(input[type="checkbox"]) {
|
||||
list-style: none;
|
||||
& > input[type="checkbox"] {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
li:has(input[type="checkbox"]) {
|
||||
list-style: none;
|
||||
}
|
||||
li:has(input[type="checkbox"]) > input[type="checkbox"] {
|
||||
margin-left: 0;
|
||||
}
|
||||
svg {
|
||||
background-color: var(--markdown-svg-background-color, none);
|
||||
@@ -137,10 +135,10 @@ export class HaMarkdown extends LitElement {
|
||||
--markdown-table-border-width: 0;
|
||||
--markdown-table-padding-inline: 0;
|
||||
--markdown-table-padding-block: 0;
|
||||
th,
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
table[role="presentation"] th,
|
||||
table[role="presentation"] td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
table[role="presentation"] td[valign="top"],
|
||||
table[role="presentation"] th[valign="top"] {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class HaPasswordField extends LitElement {
|
||||
right: 8px;
|
||||
inset-inline-start: initial;
|
||||
inset-inline-end: 8px;
|
||||
--mdc-icon-button-size: 40px;
|
||||
--ha-icon-button-size: 40px;
|
||||
--mdc-icon-size: 20px;
|
||||
color: var(--secondary-text-color);
|
||||
direction: var(--direction);
|
||||
|
||||
@@ -796,7 +796,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--ha-space-3);
|
||||
padding-top: var(--ha-space-4);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
|
||||
);
|
||||
}
|
||||
ha-combo-box-item {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: 0;
|
||||
@@ -184,8 +185,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
|
||||
|
||||
.clear {
|
||||
margin: 0 -8px;
|
||||
--mdc-icon-button-size: 32px;
|
||||
--mdc-icon-size: 20px;
|
||||
--ha-icon-button-size: 32px;
|
||||
--ha-icon-button-padding-inline: var(--ha-space-1);
|
||||
}
|
||||
.arrow {
|
||||
--mdc-icon-size: 20px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-dropdown";
|
||||
import "./ha-dropdown-item";
|
||||
import "./ha-input-helper-text";
|
||||
import "./ha-picker-field";
|
||||
import type { HaPickerField } from "./ha-picker-field";
|
||||
import "./ha-svg-icon";
|
||||
@@ -75,7 +76,7 @@ export class HaSelect extends LitElement {
|
||||
|
||||
protected override render() {
|
||||
if (this.disabled) {
|
||||
return this._renderField();
|
||||
return html`${this._renderField()}${this._renderHelper()}`;
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -116,6 +117,7 @@ export class HaSelect extends LitElement {
|
||||
)
|
||||
: html`<slot></slot>`}
|
||||
</ha-dropdown>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -131,7 +133,6 @@ export class HaSelect extends LitElement {
|
||||
aria-label=${ifDefined(this.label)}
|
||||
@clear=${this._clearValue}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.value=${valueLabel}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
@@ -144,6 +145,14 @@ export class HaSelect extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _handleSelect(ev: CustomEvent<{ item: { value: string } }>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.item.value;
|
||||
@@ -194,6 +203,11 @@ export class HaSelect extends LitElement {
|
||||
ha-dropdown::part(menu) {
|
||||
min-width: var(--select-menu-width);
|
||||
}
|
||||
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
declare global {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import type { DateSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-date-input";
|
||||
import type { HaDateInput } from "../ha-date-input";
|
||||
|
||||
@customElement("ha-selector-date")
|
||||
export class HaDateSelector extends LitElement {
|
||||
@@ -20,6 +21,12 @@ export class HaDateSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@query("ha-date-input", true) private _input?: HaDateInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-date-input
|
||||
|
||||
@@ -29,6 +29,10 @@ export class HaDateTimeSelector extends LitElement {
|
||||
|
||||
@query("ha-time-input") private _timeInput!: HaTimeInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._dateInput.reportValidity() && this._timeInput.reportValidity();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const values =
|
||||
typeof this.value === "string" ? this.value.split(" ") : undefined;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HaDurationData } from "../ha-duration-input";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
|
||||
@customElement("ha-selector-duration")
|
||||
export class HaTimeDuration extends LitElement {
|
||||
@@ -25,6 +25,12 @@ export class HaTimeDuration extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@query("ha-duration-input", true) private _input?: HaDurationInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private _data = memoizeOne(
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined => {
|
||||
if (typeof value === "number") {
|
||||
@@ -66,6 +72,7 @@ export class HaTimeDuration extends LitElement {
|
||||
.enableDay=${this.selector.duration?.enable_day}
|
||||
.enableMillisecond=${this.selector.duration?.enable_millisecond}
|
||||
.allowNegative=${this.selector.duration?.allow_negative}
|
||||
.enableSecond=${this.selector.duration?.enable_second ?? true}
|
||||
></ha-duration-input>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class HaEntitySelector extends LitElement {
|
||||
if (!this.selector.entity?.multiple) {
|
||||
return html`<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.value=${typeof this.value === "string" ? this.value : ""}
|
||||
.label=${this.label}
|
||||
.placeholder=${this.placeholder}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
} from "../../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
|
||||
import {
|
||||
brandsUrl,
|
||||
extractDomainFromBrandUrl,
|
||||
isBrandUrl,
|
||||
} from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
import "../ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../ha-form/types";
|
||||
@@ -72,16 +76,7 @@ export class HaMediaSelector extends LitElement {
|
||||
if (thumbnail === oldThumbnail) {
|
||||
return;
|
||||
}
|
||||
if (thumbnail && thumbnail.startsWith("/")) {
|
||||
this._thumbnailUrl = undefined;
|
||||
// Thumbnails served by local API require authentication
|
||||
getSignedPath(this.hass, thumbnail).then((signedPath) => {
|
||||
this._thumbnailUrl = signedPath.path;
|
||||
});
|
||||
} else if (
|
||||
thumbnail &&
|
||||
thumbnail.startsWith("https://brands.home-assistant.io")
|
||||
) {
|
||||
if (thumbnail && isBrandUrl(thumbnail)) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
this._thumbnailUrl = brandsUrl({
|
||||
@@ -89,6 +84,12 @@ export class HaMediaSelector extends LitElement {
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
} else if (thumbnail && thumbnail.startsWith("/")) {
|
||||
this._thumbnailUrl = undefined;
|
||||
// Thumbnails served by local API require authentication
|
||||
getSignedPath(this.hass, thumbnail).then((signedPath) => {
|
||||
this._thumbnailUrl = signedPath.path;
|
||||
});
|
||||
} else {
|
||||
this._thumbnailUrl = thumbnail;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
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";
|
||||
@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-slider";
|
||||
import "../ha-textfield";
|
||||
import type { HaTextField } from "../ha-textfield";
|
||||
|
||||
@customElement("ha-selector-number")
|
||||
export class HaNumberSelector extends LitElement {
|
||||
@@ -30,8 +31,14 @@ export class HaNumberSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-textfield", true) private _input?: HaTextField | HTMLInputElement;
|
||||
|
||||
private _valueStr = "";
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
if (changedProps.has("value")) {
|
||||
if (this._valueStr === "" || this.value !== Number(this._valueStr)) {
|
||||
|
||||
@@ -221,7 +221,7 @@ export class HaSelectSelector extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.getItems=${this._getItems(options)}
|
||||
.value=${this.value as string | undefined}
|
||||
.value=${typeof this.value === "string" ? this.value : undefined}
|
||||
@value-changed=${this._comboBoxValueChanged}
|
||||
allow-custom-value
|
||||
></ha-generic-picker>
|
||||
@@ -231,7 +231,7 @@ export class HaSelectSelector extends LitElement {
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${this.label ?? ""}
|
||||
.value=${(this.value as string) ?? ""}
|
||||
.value=${typeof this.value === "string" ? this.value : ""}
|
||||
.helper=${this.helper ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -64,6 +64,15 @@ const SELECTOR_SCHEMAS = {
|
||||
name: "enable_millisecond",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "enable_second",
|
||||
default: true,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "allow_negative",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const,
|
||||
entity: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { StringSelector } from "../../data/selector";
|
||||
@@ -32,11 +32,18 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
@state() private _unmaskedPassword = false;
|
||||
|
||||
@query("ha-textfield, ha-textarea") private _input?: HTMLInputElement;
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
(
|
||||
this.renderRoot.querySelector("ha-textarea, ha-textfield") as HTMLElement
|
||||
)?.focus();
|
||||
this._input?.focus();
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
if (this.selector.text?.multiple) {
|
||||
return true;
|
||||
}
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -141,7 +148,7 @@ export class HaTextSelector extends LitElement {
|
||||
right: 8px;
|
||||
inset-inline-start: initial;
|
||||
inset-inline-end: 8px;
|
||||
--mdc-icon-button-size: 40px;
|
||||
--ha-icon-button-size: 40px;
|
||||
--mdc-icon-size: 20px;
|
||||
color: var(--secondary-text-color);
|
||||
direction: var(--direction);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import type { TimeSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-time-input";
|
||||
import type { HaTimeInput } from "../ha-time-input";
|
||||
|
||||
@customElement("ha-selector-time")
|
||||
export class HaTimeSelector extends LitElement {
|
||||
@@ -20,6 +21,12 @@ export class HaTimeSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@query("ha-time-input") private _input?: HaTimeInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-time-input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import type { Selector } from "../../data/selector";
|
||||
@@ -94,9 +94,27 @@ export class HaSelector extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public context?: Record<string, any>;
|
||||
|
||||
@query("#selector", true) private _selectorElement?: HTMLElement;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
if (
|
||||
this._selectorElement &&
|
||||
"reportValidity" in this._selectorElement &&
|
||||
typeof this._selectorElement.reportValidity === "function"
|
||||
) {
|
||||
return this._selectorElement?.reportValidity() ?? true;
|
||||
}
|
||||
if (this.required) {
|
||||
return (
|
||||
this.value !== undefined && this.value !== null && this.value !== ""
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
(this.renderRoot.querySelector("#selector") as HTMLElement)?.focus();
|
||||
this._selectorElement?.focus();
|
||||
}
|
||||
|
||||
private get _type() {
|
||||
|
||||
@@ -37,8 +37,8 @@ import { subscribeRepairsIssueRegistry } from "../data/repairs";
|
||||
import type { UpdateEntity } from "../data/update";
|
||||
import { updateCanInstall } from "../data/update";
|
||||
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant, PanelInfo, Route } from "../types";
|
||||
@@ -144,6 +144,7 @@ export const computePanels = memoizeOne(
|
||||
if (
|
||||
!isDefaultPanel &&
|
||||
(!panel.title ||
|
||||
panel.show_in_sidebar === false ||
|
||||
hiddenPanels.includes(panel.url_path) ||
|
||||
(panel.default_visible === false &&
|
||||
!panelsOrder.includes(panel.url_path)))
|
||||
@@ -980,7 +981,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
ha-md-list-item,
|
||||
ha-md-list-item .item-text,
|
||||
.title {
|
||||
transition: none;
|
||||
transition: 1ms;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { useAmPm } from "../common/datetime/use_am_pm";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { FrontendLocaleData } from "../data/translation";
|
||||
import "./ha-base-time-input";
|
||||
import type { TimeChangedEvent } from "./ha-base-time-input";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import "./ha-base-time-input";
|
||||
import type { HaBaseTimeInput, TimeChangedEvent } from "./ha-base-time-input";
|
||||
|
||||
@customElement("ha-time-input")
|
||||
export class HaTimeInput extends LitElement {
|
||||
@@ -26,6 +26,12 @@ export class HaTimeInput extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
|
||||
|
||||
@query("ha-base-time-input") private _input?: HaBaseTimeInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const useAMPM = useAmPm(this.locale);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export class HaTopAppBarFixed extends TopAppBarFixedBase {
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mdc-top-app-bar {
|
||||
transition: none;
|
||||
transition: 1ms;
|
||||
}
|
||||
}
|
||||
.mdc-top-app-bar__title {
|
||||
|
||||
@@ -298,7 +298,7 @@ export class TopAppBarBaseBase extends BaseElement {
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mdc-top-app-bar {
|
||||
transition: none;
|
||||
transition: 1ms;
|
||||
}
|
||||
}
|
||||
.mdc-top-app-bar--pane.mdc-top-app-bar--fixed-scrolled {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { Segment } from "../data/vacuum";
|
||||
import { getVacuumSegments } from "../data/vacuum";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-area-picker";
|
||||
import "./ha-md-list";
|
||||
import "./ha-md-list-item";
|
||||
|
||||
type AreaSegmentMapping = Record<string, string[]>; // area ID -> segment IDs
|
||||
|
||||
@customElement("ha-vacuum-segment-area-mapper")
|
||||
export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "entity-id" }) public entityId!: string;
|
||||
|
||||
@property({ attribute: false }) public value?: AreaSegmentMapping;
|
||||
|
||||
@state() private _segments?: Segment[];
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
public get lastSeenSegments() {
|
||||
return this._segments;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
this._loadSegments();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadSegments() {
|
||||
this._loading = true;
|
||||
this._error = undefined;
|
||||
|
||||
try {
|
||||
const result = await getVacuumSegments(this.hass, this.entityId);
|
||||
this._segments = result.segments;
|
||||
} catch (err: any) {
|
||||
this._error = err.message || "Failed to load segments";
|
||||
this._segments = undefined;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._loading) {
|
||||
return html`
|
||||
<div class="loading">${this.hass.localize("ui.common.loading")}...</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this._error) {
|
||||
return html` <ha-alert alert-type="error">${this._error}</ha-alert> `;
|
||||
}
|
||||
|
||||
if (!this._segments || this._segments.length === 0) {
|
||||
return html`
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize("ui.dialogs.vacuum_segment_mapping.no_segments")}
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
// Group segments by group (if available)
|
||||
const groupedSegments = this._groupSegments(this._segments);
|
||||
|
||||
return html`
|
||||
${Object.entries(groupedSegments).map(
|
||||
([groupName, segments]) => html`
|
||||
${groupName ? html`<h2>${groupName}</h2>` : nothing}
|
||||
<ha-md-list>
|
||||
${segments.map((segment) => this._renderSegment(segment))}
|
||||
</ha-md-list>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _groupSegments(segments: Segment[]): Record<string, Segment[]> {
|
||||
const grouped: Record<string, Segment[]> = {};
|
||||
|
||||
for (const segment of segments) {
|
||||
const group = segment.group || "";
|
||||
if (!grouped[group]) {
|
||||
grouped[group] = [];
|
||||
}
|
||||
grouped[group].push(segment);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private _renderSegment(segment: Segment) {
|
||||
const mappedAreas = this._getSegmentAreas(segment.id);
|
||||
|
||||
return html`
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">${segment.name}</span>
|
||||
<ha-area-picker
|
||||
slot="end"
|
||||
.hass=${this.hass}
|
||||
.value=${mappedAreas}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.area_label"
|
||||
)}
|
||||
@value-changed=${this._handleAreaChanged}
|
||||
data-segment-id=${segment.id}
|
||||
></ha-area-picker>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAreaChanged = (ev: CustomEvent) => {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
const segmentId = target.dataset.segmentId;
|
||||
if (segmentId) {
|
||||
this._areaChanged(segmentId, ev);
|
||||
}
|
||||
};
|
||||
|
||||
private _getSegmentAreas(segmentId: string): string | undefined {
|
||||
if (!this.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find which area(s) contain this segment
|
||||
for (const [areaId, segmentIds] of Object.entries(this.value)) {
|
||||
if (segmentIds.includes(segmentId)) {
|
||||
return areaId;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _areaChanged(segmentId: string, ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const newAreaId = ev.detail.value as string | undefined;
|
||||
|
||||
// Create a copy of the current mapping
|
||||
const newMapping: AreaSegmentMapping = { ...this.value };
|
||||
|
||||
// Remove segment from all areas
|
||||
for (const areaId of Object.keys(newMapping)) {
|
||||
newMapping[areaId] = newMapping[areaId].filter((id) => id !== segmentId);
|
||||
// Remove empty area entries
|
||||
if (newMapping[areaId].length === 0) {
|
||||
delete newMapping[areaId];
|
||||
}
|
||||
}
|
||||
|
||||
// Add segment to new area if specified
|
||||
if (newAreaId) {
|
||||
if (!newMapping[newAreaId]) {
|
||||
newMapping[newAreaId] = [];
|
||||
}
|
||||
newMapping[newAreaId].push(segmentId);
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", { value: newMapping });
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-area-picker {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
margin-inline-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: var(--ha-space-4);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-vacuum-segment-area-mapper": HaVacuumSegmentAreaMapper;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ export class HaYamlEditor extends LitElement {
|
||||
@property({ type: Boolean, attribute: "disable-fullscreen" })
|
||||
public disableFullscreen = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "in-dialog" })
|
||||
public inDialog = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property({ attribute: "copy-clipboard", type: Boolean })
|
||||
@@ -101,6 +104,13 @@ export class HaYamlEditor extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public disableCodeEditorFullscreen(): void {
|
||||
this.disableFullscreen = true;
|
||||
if (this._codeEditor) {
|
||||
this._codeEditor.disableFullscreen = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._yaml === undefined) {
|
||||
return nothing;
|
||||
@@ -114,6 +124,7 @@ export class HaYamlEditor extends LitElement {
|
||||
.value=${this._yaml}
|
||||
.readOnly=${this.readOnly}
|
||||
.disableFullscreen=${this.disableFullscreen}
|
||||
.inDialog=${this.inDialog}
|
||||
mode="yaml"
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
|
||||
@@ -423,31 +423,77 @@ export class HaMap extends ReactiveElement {
|
||||
? baseOpacity! + pointIndex * opacityStep!
|
||||
: undefined;
|
||||
|
||||
const thisPoint = path.points[pointIndex];
|
||||
const nextPoint = path.points[pointIndex + 1];
|
||||
|
||||
// DRAW point
|
||||
this._mapPaths.push(
|
||||
Leaflet.circleMarker(path.points[pointIndex].point, {
|
||||
Leaflet.circleMarker(thisPoint.point, {
|
||||
radius: isTouch ? 8 : 3,
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
fillOpacity: opacity,
|
||||
interactive: true,
|
||||
}).bindTooltip(
|
||||
this._computePathTooltip(path, path.points[pointIndex]),
|
||||
{ direction: "top" }
|
||||
)
|
||||
}).bindTooltip(this._computePathTooltip(path, thisPoint), {
|
||||
direction: "top",
|
||||
})
|
||||
);
|
||||
|
||||
// DRAW line between this and next point
|
||||
this._mapPaths.push(
|
||||
Leaflet.polyline(
|
||||
[path.points[pointIndex].point, path.points[pointIndex + 1].point],
|
||||
{
|
||||
if (Math.abs(thisPoint.point[1] - nextPoint.point[1]) <= 180) {
|
||||
// if the path does not cross the antimeridian, draw a simple line
|
||||
// between the two points
|
||||
this._mapPaths.push(
|
||||
Leaflet.polyline([thisPoint.point, nextPoint.point], {
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
interactive: false,
|
||||
}
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// if the path crosses the antimeridian, split the line into two, to
|
||||
// avoid it being drawn across the entire map
|
||||
const longitudeDifference =
|
||||
((nextPoint.point[1] - thisPoint.point[1] + 540) % 360) - 180;
|
||||
let intersectionLatitude: number;
|
||||
if (longitudeDifference === 0) {
|
||||
// very, very unlikely edge case
|
||||
intersectionLatitude =
|
||||
(thisPoint.point[0] + nextPoint.point[0]) / 2;
|
||||
} else {
|
||||
intersectionLatitude =
|
||||
thisPoint.point[0] +
|
||||
((nextPoint.point[0] - thisPoint.point[0]) *
|
||||
(thisPoint.point[1] > 0
|
||||
? 180 - thisPoint.point[1]
|
||||
: -180 - thisPoint.point[1])) /
|
||||
longitudeDifference;
|
||||
}
|
||||
|
||||
const intersectionPoint1: LatLngTuple = [
|
||||
intersectionLatitude,
|
||||
thisPoint.point[1] > 0 ? 180 : -180,
|
||||
];
|
||||
const intersectionPoint2: LatLngTuple = [
|
||||
intersectionLatitude,
|
||||
nextPoint.point[1] > 0 ? 180 : -180,
|
||||
];
|
||||
|
||||
this._mapPaths.push(
|
||||
Leaflet.polyline([thisPoint.point, intersectionPoint1], {
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
interactive: false,
|
||||
})
|
||||
);
|
||||
this._mapPaths.push(
|
||||
Leaflet.polyline([intersectionPoint2, nextPoint.point], {
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
interactive: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
const pointIndex = path.points.length - 1;
|
||||
if (pointIndex >= 0) {
|
||||
|
||||
@@ -242,6 +242,10 @@ class BrowseMediaTTS extends LitElement {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
ha-language-picker {
|
||||
width: 100%;
|
||||
}
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
@@ -260,7 +264,7 @@ class BrowseMediaTTS extends LitElement {
|
||||
}
|
||||
.footer {
|
||||
--mdc-icon-size: 14px;
|
||||
--mdc-icon-button-size: 24px;
|
||||
--ha-icon-button-size: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from "../../data/media_source";
|
||||
import { isTTSMediaSource } from "../../data/tts";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import {
|
||||
@@ -584,7 +584,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
})}
|
||||
.items=${children}
|
||||
.renderItem=${this._renderGridItem}
|
||||
class="children ${classMap({
|
||||
class="children ha-scrollbar ${classMap({
|
||||
portrait:
|
||||
childrenMediaClass.thumbnail_ratio ===
|
||||
"portrait",
|
||||
@@ -612,6 +612,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
style=${styleMap({
|
||||
height: `${children.length * 72 + 26}px`,
|
||||
})}
|
||||
class="ha-scrollbar"
|
||||
.renderItem=${this._renderListItem}
|
||||
></lit-virtualizer>
|
||||
${currentItem.not_shown
|
||||
@@ -764,6 +765,16 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (isBrandUrl(thumbnailUrl)) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
return brandsUrl({
|
||||
domain: extractDomainFromBrandUrl(thumbnailUrl),
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (thumbnailUrl.startsWith("/")) {
|
||||
// Thumbnails served by local API require authentication
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -786,16 +797,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (isBrandUrl(thumbnailUrl)) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
thumbnailUrl = brandsUrl({
|
||||
domain: extractDomainFromBrandUrl(thumbnailUrl),
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
}
|
||||
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
||||
@@ -979,6 +980,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
@@ -1232,7 +1234,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
|
||||
.child .play:not(.can_expand) {
|
||||
--mdc-icon-button-size: 70px;
|
||||
--ha-icon-button-size: 70px;
|
||||
--mdc-icon-size: 48px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
@@ -1293,7 +1295,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
transition: all 0.5s;
|
||||
background-color: rgba(var(--rgb-card-background-color), 0.5);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
--mdc-icon-button-size: 40px;
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
|
||||
ha-list-item:hover .graphic .play {
|
||||
|
||||
@@ -93,8 +93,8 @@ class SearchInputOutlined extends LitElement {
|
||||
}
|
||||
ha-svg-icon,
|
||||
ha-icon-button {
|
||||
--mdc-icon-button-size: 24px;
|
||||
height: var(--mdc-icon-button-size);
|
||||
--ha-icon-button-size: 24px;
|
||||
height: var(--ha-icon-button-size);
|
||||
display: flex;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
@@ -641,7 +641,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
z-index: 1;
|
||||
}
|
||||
ha-icon-button {
|
||||
--mdc-icon-button-size: 32px;
|
||||
--ha-icon-button-size: 32px;
|
||||
}
|
||||
.summary {
|
||||
display: flex;
|
||||
|
||||
@@ -247,7 +247,7 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
cursor: default;
|
||||
}
|
||||
.mdc-chip ha-icon-button {
|
||||
--mdc-icon-button-size: 24px;
|
||||
--ha-icon-button-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
|
||||
@@ -159,6 +159,9 @@ export interface GasSourceTypeEnergyPreference {
|
||||
// kWh/volume meter
|
||||
stat_energy_from: string;
|
||||
|
||||
// Flow rate (m³/h, L/min, etc.)
|
||||
stat_rate?: string;
|
||||
|
||||
// $ meter
|
||||
stat_cost: string | null;
|
||||
|
||||
@@ -174,6 +177,9 @@ export interface WaterSourceTypeEnergyPreference {
|
||||
// volume meter
|
||||
stat_energy_from: string;
|
||||
|
||||
// Flow rate (L/min, gal/min, m³/h, etc.)
|
||||
stat_rate?: string;
|
||||
|
||||
// $ meter
|
||||
stat_cost: string | null;
|
||||
|
||||
@@ -368,6 +374,9 @@ export const getReferencedStatisticIdsPower = (
|
||||
|
||||
for (const source of prefs.energy_sources) {
|
||||
if (source.type === "gas" || source.type === "water") {
|
||||
if (source.stat_rate) {
|
||||
statIDs.push(source.stat_rate);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -389,6 +398,7 @@ export const getReferencedStatisticIdsPower = (
|
||||
}
|
||||
}
|
||||
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
|
||||
statIDs.push(...prefs.device_consumption_water.map((d) => d.stat_rate));
|
||||
|
||||
return statIDs.filter(Boolean) as string[];
|
||||
};
|
||||
@@ -1391,6 +1401,80 @@ export const calculateSolarConsumedGauge = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Conversion factors from each flow rate unit to L/min.
|
||||
* All HA-supported UnitOfVolumeFlowRate values are covered.
|
||||
*
|
||||
* m³/h → 1000/60 = 16.6667 L/min
|
||||
* m³/min → 1000 L/min
|
||||
* m³/s → 60000 L/min
|
||||
* ft³/min→ 28.3168 L/min
|
||||
* L/h → 1/60 L/min
|
||||
* L/min → 1 L/min
|
||||
* L/s → 60 L/min
|
||||
* gal/h → 3.78541/60 L/min
|
||||
* gal/min→ 3.78541 L/min
|
||||
* gal/d → 3.78541/1440 L/min
|
||||
* mL/s → 0.06 L/min
|
||||
*/
|
||||
|
||||
/** Exact number of liters in one US gallon */
|
||||
const LITERS_PER_GALLON = 3.785411784;
|
||||
|
||||
const FLOW_RATE_TO_LMIN: Record<string, number> = {
|
||||
"m³/h": 1000 / 60,
|
||||
"m³/min": 1000,
|
||||
"m³/s": 60000,
|
||||
"ft³/min": 28.316846592,
|
||||
"L/h": 1 / 60,
|
||||
"L/min": 1,
|
||||
"L/s": 60,
|
||||
"gal/h": LITERS_PER_GALLON / 60,
|
||||
"gal/min": LITERS_PER_GALLON,
|
||||
"gal/d": LITERS_PER_GALLON / 1440,
|
||||
"mL/s": 60 / 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current flow rate from an entity state, converted to L/min.
|
||||
* @returns Flow rate in L/min, or undefined if unavailable/invalid.
|
||||
*/
|
||||
export const getFlowRateFromState = (
|
||||
stateObj?: HassEntity
|
||||
): number | undefined => {
|
||||
if (!stateObj) {
|
||||
return undefined;
|
||||
}
|
||||
const value = parseFloat(stateObj.state);
|
||||
if (isNaN(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const unit = stateObj.attributes.unit_of_measurement;
|
||||
const factor = unit ? FLOW_RATE_TO_LMIN[unit] : undefined;
|
||||
if (factor === undefined) {
|
||||
// Unknown unit – return raw value as-is (best effort)
|
||||
return value;
|
||||
}
|
||||
return value * factor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a flow rate value (in L/min) to a human-readable string using
|
||||
* the preferred unit system: metric → L/min, imperial → gal/min.
|
||||
*/
|
||||
export const formatFlowRateShort = (
|
||||
hassLocale: HomeAssistant["locale"],
|
||||
lengthUnitSystem: string,
|
||||
litersPerMin: number
|
||||
): string => {
|
||||
const isMetric = lengthUnitSystem === "km";
|
||||
if (isMetric) {
|
||||
return `${formatNumber(litersPerMin, hassLocale, { maximumFractionDigits: 1 })} L/min`;
|
||||
}
|
||||
const galPerMin = litersPerMin / LITERS_PER_GALLON;
|
||||
return `${formatNumber(galPerMin, hassLocale, { maximumFractionDigits: 1 })} gal/min`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current power value from entity state, normalized to watts (W)
|
||||
* @param stateObj - The entity state object to get power value from
|
||||
|
||||
@@ -3,7 +3,6 @@ import { formatDurationDigital } from "../../common/datetime/format_duration";
|
||||
import type { FrontendLocaleData } from "../translation";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
|
||||
// These attributes are hidden from the more-info window for all entities.
|
||||
export const STATE_ATTRIBUTES = [
|
||||
"entity_id",
|
||||
"assumed_state",
|
||||
@@ -29,8 +28,6 @@ export const STATE_ATTRIBUTES = [
|
||||
"available_tones",
|
||||
];
|
||||
|
||||
// These attributes are hidden from the more-info window for entities of the
|
||||
// matching domain and device_class.
|
||||
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
|
||||
sensor: {
|
||||
enum: ["options"],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { debounce } from "../../common/util/debounce";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LightColor } from "../light";
|
||||
import type { RegistryEntry } from "../registry";
|
||||
import type { Segment } from "../vacuum";
|
||||
|
||||
type EntityCategory = "config" | "diagnostic";
|
||||
|
||||
@@ -120,6 +121,11 @@ export interface SwitchAsXEntityOptions {
|
||||
invert: boolean;
|
||||
}
|
||||
|
||||
export interface VacuumEntityOptions {
|
||||
area_mapping?: Record<string, string[]>;
|
||||
last_seen_segments?: Segment[];
|
||||
}
|
||||
|
||||
export interface EntityRegistryOptions {
|
||||
number?: NumberEntityOptions;
|
||||
sensor?: SensorEntityOptions;
|
||||
@@ -128,6 +134,7 @@ export interface EntityRegistryOptions {
|
||||
lock?: LockEntityOptions;
|
||||
weather?: WeatherEntityOptions;
|
||||
light?: LightEntityOptions;
|
||||
vacuum?: VacuumEntityOptions;
|
||||
switch_as_x?: SwitchAsXEntityOptions;
|
||||
conversation?: Record<string, unknown>;
|
||||
"cloud.alexa"?: Record<string, unknown>;
|
||||
@@ -150,7 +157,8 @@ export interface EntityRegistryEntryUpdateParams {
|
||||
| AlarmControlPanelEntityOptions
|
||||
| CalendarEntityOptions
|
||||
| WeatherEntityOptions
|
||||
| LightEntityOptions;
|
||||
| LightEntityOptions
|
||||
| VacuumEntityOptions;
|
||||
aliases?: string[];
|
||||
labels?: string[];
|
||||
categories?: Record<string, string | null>;
|
||||
|
||||
@@ -37,6 +37,13 @@ export interface LovelaceViewHeaderConfig {
|
||||
badges_wrap?: "wrap" | "scroll";
|
||||
}
|
||||
|
||||
export const DEFAULT_FOOTER_MAX_WIDTH_PX = 600;
|
||||
|
||||
export interface LovelaceViewFooterConfig {
|
||||
card?: LovelaceCardConfig;
|
||||
max_width?: number;
|
||||
}
|
||||
|
||||
export interface LovelaceViewSidebarConfig {
|
||||
sections?: LovelaceSectionConfig[];
|
||||
content_label?: string;
|
||||
@@ -68,6 +75,7 @@ export interface LovelaceViewConfig extends LovelaceBaseViewConfig {
|
||||
cards?: LovelaceCardConfig[];
|
||||
sections?: LovelaceSectionRawConfig[];
|
||||
header?: LovelaceViewHeaderConfig;
|
||||
footer?: LovelaceViewFooterConfig;
|
||||
// Only used for section view, it should move to a section view config type when the views will have dedicated editor.
|
||||
sidebar?: LovelaceViewSidebarConfig;
|
||||
}
|
||||
|
||||
+35
-8
@@ -8,12 +8,17 @@ import {
|
||||
mdiPlayBoxMultiple,
|
||||
mdiTooltipAccount,
|
||||
} from "@mdi/js";
|
||||
import type { HomeAssistant, PanelInfo } from "../types";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant, PanelInfo } from "../types";
|
||||
|
||||
export const HOME_PANEL = "home";
|
||||
export const NOT_FOUND_PANEL = "notfound";
|
||||
export const PROFILE_PANEL = "profile";
|
||||
export const LOVELACE_PANEL = "lovelace";
|
||||
|
||||
/** Panel to show when no panel is picked. */
|
||||
export const DEFAULT_PANEL = "home";
|
||||
export const DEFAULT_PANEL = HOME_PANEL;
|
||||
|
||||
export const hasLegacyOverviewPanel = (hass: HomeAssistant): boolean =>
|
||||
Boolean(hass.panels.lovelace?.config);
|
||||
@@ -30,7 +35,7 @@ export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
|
||||
getLegacyDefaultPanelUrlPath() ||
|
||||
DEFAULT_PANEL;
|
||||
// If default panel is lovelace and no old overview exists, fall back to home
|
||||
if (defaultPanel === "lovelace" && !hasLegacyOverviewPanel(hass)) {
|
||||
if (defaultPanel === LOVELACE_PANEL && !hasLegacyOverviewPanel(hass)) {
|
||||
return DEFAULT_PANEL;
|
||||
}
|
||||
return defaultPanel;
|
||||
@@ -39,12 +44,16 @@ export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
|
||||
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
|
||||
const panel = getDefaultPanelUrlPath(hass);
|
||||
|
||||
return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
|
||||
return (
|
||||
(panel ? hass.panels[panel] : undefined) ??
|
||||
hass.panels[DEFAULT_PANEL] ??
|
||||
hass.panels[NOT_FOUND_PANEL]
|
||||
);
|
||||
};
|
||||
|
||||
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
|
||||
if (panel.url_path === "profile") {
|
||||
return "panel.profile" as const;
|
||||
if ([PROFILE_PANEL, NOT_FOUND_PANEL].includes(panel.url_path)) {
|
||||
return `panel.${panel.url_path}` as const;
|
||||
}
|
||||
|
||||
return `panel.${panel.title}` as const;
|
||||
@@ -137,4 +146,22 @@ export const PANEL_ICON_PATHS = {
|
||||
export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
|
||||
PANEL_ICON_PATHS[panel.url_path];
|
||||
|
||||
export const FIXED_PANELS = ["profile", "config"];
|
||||
export const FIXED_PANELS = [PROFILE_PANEL, "config", NOT_FOUND_PANEL];
|
||||
|
||||
export interface PanelMutableParams {
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
require_admin?: boolean | null;
|
||||
show_in_sidebar?: boolean | null;
|
||||
}
|
||||
|
||||
export const updatePanel = (
|
||||
hass: HomeAssistant,
|
||||
urlPath: string,
|
||||
updates: PanelMutableParams
|
||||
) =>
|
||||
hass.callWS({
|
||||
type: "frontend/update_panel",
|
||||
url_path: urlPath,
|
||||
...updates,
|
||||
});
|
||||
|
||||
@@ -231,6 +231,7 @@ export interface DurationSelector {
|
||||
enable_day?: boolean;
|
||||
enable_millisecond?: boolean;
|
||||
allow_negative?: boolean;
|
||||
enable_second?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,23 @@ export type SystemLogLevel =
|
||||
| "info"
|
||||
| "debug";
|
||||
|
||||
export type SystemLogErrorType =
|
||||
| "auth"
|
||||
| "connection"
|
||||
| "invalid_response"
|
||||
| "rate_limit"
|
||||
| "server"
|
||||
| "slow_setup"
|
||||
| "timeout"
|
||||
| "ssl"
|
||||
| "statistics"
|
||||
| "dns";
|
||||
|
||||
export interface LoggedError {
|
||||
name: string;
|
||||
message: [string];
|
||||
level: SystemLogLevel;
|
||||
error_type?: SystemLogErrorType;
|
||||
source: [string, number];
|
||||
exception: string;
|
||||
count: number;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user