Compare commits

..

66 Commits

Author SHA1 Message Date
Bram Kragten
ccc48d158a Bumped version to 20251105.1 2025-11-21 13:50:20 +01:00
Bram Kragten
b11e787f09 Dont add store token for external auth flows (#28026)
* Dont add store token for external auth flows

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 13:50:04 +01:00
renovate[bot]
cdb6562de8 Update dependency js-yaml to v4.1.1 [SECURITY] (#27955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-21 13:48:48 +01:00
karwosts
d8e8c9aa02 Fix media image on dashboard-level background (#27934) 2025-11-21 13:48:10 +01:00
Petar Petrov
be392be1e6 Increase ZHA reconfiguration dialog width for details view (#27909) 2025-11-21 13:44:41 +01:00
Petar Petrov
10dc432445 Fix entity name in statistics chart (#27896) 2025-11-21 13:44:40 +01:00
Wendelin
19187f887d Fix target picker for entity_id: none (#27893)
Fix notFound condition to exclude 'none' in ha-target-picker-item-row
2025-11-21 13:44:40 +01:00
Petar Petrov
dc76a42aaa Smooth sensor card more when "Show more detail" is disabled (#27891)
* Smooth sensor card more when "Show more detail" is disabled

* Set minimum sample points to 10
2025-11-21 13:44:38 +01:00
Wendelin
1f2b8047a6 Use ha-ripple in ha-md-list-item (#27889) 2025-11-21 13:44:38 +01:00
Petar Petrov
e8c9ed0528 Dynamic total energy for pie chart (#27883) 2025-11-21 13:44:37 +01:00
Petar Petrov
c7ae78c02f Fix chart label outline color (#27882) 2025-11-21 13:44:36 +01:00
karwosts
dc8f1211e6 Fix entity editor with non-existant entity (#27875) 2025-11-21 13:44:35 +01:00
Yuksel Beyti
5c25a63ea5 Fix malformed HTML tags in backup backups component (#27872) 2025-11-21 13:44:34 +01:00
Paul Bottein
c1787ab994 Fix backup download and delete actions (#27851) 2025-11-21 13:44:33 +01:00
Paul Bottein
6fea535fdc Fix OHF logo theme (#27830) 2025-11-21 13:44:32 +01:00
Wendelin
e8cee84380 Fix floor details area picker (#27827) 2025-11-21 13:44:31 +01:00
Wendelin
b4613edeb7 Target picker row check if not found entity isn't "all" (#27826)
Target picker row check if not found entity isn't all
2025-11-21 13:44:30 +01:00
Wendelin
a8b6e5aa3d Add trigger/condition/action dialog: select single search result with enter key (#27825)
* Add trigger/condition/action dialog: select single search result with enter key

* Update src/panels/config/automation/add-automation-element-dialog.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 13:44:29 +01:00
Jan Bouwhuis
e842193cd6 Fix index for service action translation in service action dialog (#27824) 2025-11-21 13:44:28 +01:00
Bram Kragten
bb0813333d Fix landing page build (#27817) 2025-11-21 13:44:27 +01:00
Petar Petrov
ab4c6f80f4 Disable graph resize animation for general resizing (#27816) 2025-11-21 13:44:26 +01:00
Bram Kragten
89796e425a Bumped version to 20251105.0 2025-11-05 15:26:35 +01:00
Wendelin
9c42c8bbc4 Add fallback icon for domain template (#27814) 2025-11-05 15:25:54 +01:00
Wendelin
616237caee Fix target picker with empty sections (#27813) 2025-11-05 15:25:53 +01:00
Wendelin
2d36a0d37f Add trigger/condition/action dialog - Show device group always on top (#27812)
add automation element dialog Device always on top
2025-11-05 15:25:52 +01:00
Wendelin
1ec432a20f Change add trigger/condition/action dialog title (#27811)
Change add dialog title
2025-11-05 15:25:51 +01:00
Wendelin
afd91b2261 Fix auth language picker styles (#27805) 2025-11-05 15:25:50 +01:00
Paul Bottein
cdfb7f914f Fix target picker in logbook card editor (#27804)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-11-05 15:24:49 +01:00
Wendelin
33b0897522 Add trigger/condition/action dialog: fix empty elements in search results (#27802) 2025-11-05 15:16:14 +01:00
Wendelin
5f0cf1b522 Add condition/action dialog: blocks title (#27801) 2025-11-05 15:16:13 +01:00
Wendelin
afb2ad95a4 Fix target picker in card editor (#27800) 2025-11-05 15:16:12 +01:00
Jan-Philipp Benecke
27beab3133 Fix z-index for target picker item row icon (#27798) 2025-11-05 15:16:10 +01:00
Wendelin
435c82489b Fix assist conversation language picker (#27764) 2025-11-05 15:16:09 +01:00
Bram Kragten
3ba6bf272e Bumped version to 20251104.0 2025-11-04 18:21:11 +01:00
Paul Bottein
eec99b2fa3 Rename safety panel to security panel (#27796) 2025-11-04 18:20:10 +01:00
Bram Kragten
d23e45e410 Handle unknown items in target picker (#27795)
* Handle unknown items in target picker

* Update ha-target-picker-item-row.ts

* update colors

* fallback to domain icons
2025-11-04 18:19:02 +01:00
Paul Bottein
3c82d12609 Auto refresh summary dashboard when registries changed (#27794) 2025-11-04 18:17:46 +01:00
Paul Bottein
15d67997e7 Don't show summary card if summary dashboards are empty (#27788)
Don't show summary card if summary dashboard are empty
2025-11-04 18:16:37 +01:00
Paul Bottein
a6dfcb3100 Fix tooltip hide delay (#27786) 2025-11-04 18:16:37 +01:00
karwosts
26c2369228 Fix sankey with external statistics devices (#27784) 2025-11-04 18:16:35 +01:00
Paul Bottein
2eed446492 Display entities without area in summary dashboard (#27777)
* Add support for no area, no floor and no device in entity filter

* Display entities without area in summary dashboard
2025-11-04 18:16:34 +01:00
Wendelin
7ebdeab6b2 Fix-labels-yaml-helper (#27776)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-11-04 18:16:33 +01:00
Tobias Bieniek
0c35278f51 Hide media players summary when no entities exist (#27642) 2025-11-04 18:16:32 +01:00
Bram Kragten
561122f03d Bumped version to 20251103.0 2025-11-03 16:34:11 +01:00
Petar Petrov
95311be034 Apply theme variables to pi charts (#27773) 2025-11-03 16:34:07 +01:00
Wendelin
1eda44a102 Fix selected element text color (#27771) 2025-11-03 16:32:51 +01:00
Petar Petrov
d76781eb91 Fix for Y axis label formatting in history graph (#27770) 2025-11-03 16:32:50 +01:00
Petar Petrov
82d44e051f Fix sensor card graph in Safari (#27768) 2025-11-03 16:32:49 +01:00
Aidan Timson
fdc9f5a3b7 Use supervisor endpoint for downloading logs (when avaliable) (#27765) 2025-11-03 16:32:47 +01:00
Paul Bottein
ee6c82aba9 Don't show tooltip on overflow menu in dashboard toolbar (#27763) 2025-11-03 16:32:46 +01:00
Paul Bottein
11d3f5c2ba Fix suggest cards dialog for sections view (#27762) 2025-11-03 16:32:45 +01:00
Aarni Koskela
feb68ce373 Add support for PM4 sensor state (#27754) 2025-11-03 16:32:44 +01:00
Simon Lamon
7f9a9de157 Move label translations to ui.dialog (#27752) 2025-11-03 16:32:43 +01:00
Simon Lamon
8e1b6a3d3b Fixes in backup overflow (#27745)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-11-03 16:32:41 +01:00
Jan-Philipp Benecke
6e6e5a53e2 Fix button text overflow (#27744) 2025-11-03 16:32:41 +01:00
Bram Kragten
0408734ec5 Bumped version to 20251029.1 2025-10-30 18:19:31 +01:00
Paul Bottein
317519fc08 Revert "Fix entities card size and add grid contstraints" (#27725) 2025-10-30 18:18:27 +01:00
Paul Bottein
843d79eab4 Don't show tooltip for ha button menu in top bar (#27723) 2025-10-30 18:18:26 +01:00
Paul Bottein
165a757f06 Revert entity naming in target picker chips (#27722) 2025-10-30 18:18:25 +01:00
Aidan Timson
ea8b730142 Revert "Migrate dialog-device-registry-detail to ha-wa-dialog (#27668)" (#27716)
This reverts commit 2a8d935601.
2025-10-30 18:18:24 +01:00
Paul Bottein
e88c97d625 Use entity naming in more cards (#27714)
* Use entity naming in more cards

* Migrate statistic card

* Fix localize
2025-10-30 18:18:23 +01:00
Aidan Timson
7560988b76 Trend feature: make sure content is centered when loading (#27708)
* Make sure content is centered when loading

* Restore from test
2025-10-30 18:18:22 +01:00
Aidan Timson
eecd8077b6 Calendar card height: account for title and stop overflow (#27707) 2025-10-30 18:18:21 +01:00
Paul Bottein
cbab5c3f7b Restore trigger id in overflow menu for trigger (#27702) 2025-10-30 18:18:20 +01:00
Paul Bottein
a5d27c8bb8 Only clear from and to trigger in state trigger (#27700) 2025-10-30 18:18:19 +01:00
Paul Bottein
a6a340b5db Only display add button if at least one entity is selected in entities picker (#27699) 2025-10-30 18:18:18 +01:00
252 changed files with 4330 additions and 12899 deletions

View File

@@ -2,8 +2,6 @@
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control. You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
**Note**: This file contains high-level guidelines and references to implementation patterns. For detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
## Table of Contents ## Table of Contents
- [Quick Reference](#quick-reference) - [Quick Reference](#quick-reference)
@@ -153,10 +151,6 @@ try {
### Styling Guidelines ### Styling Guidelines
- **Use CSS custom properties**: Leverage the theme system - **Use CSS custom properties**: Leverage the theme system
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
- Spacing scale: `--ha-space-0` (0px) through `--ha-space-20` (80px) in 4px increments
- Defined in `src/resources/theme/core.globals.ts`
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
- **Mobile-first responsive**: Design for mobile, enhance for desktop - **Mobile-first responsive**: Design for mobile, enhance for desktop
- **Follow Material Design**: Use Material Web Components where appropriate - **Follow Material Design**: Use Material Web Components where appropriate
- **Support RTL**: Ensure all layouts work in RTL languages - **Support RTL**: Ensure all layouts work in RTL languages
@@ -165,68 +159,21 @@ try {
static get styles() { static get styles() {
return css` return css`
:host { :host {
padding: var(--ha-space-4); --spacing: 16px;
padding: var(--spacing);
color: var(--primary-text-color); color: var(--primary-text-color);
background-color: var(--card-background-color); background-color: var(--card-background-color);
} }
.content {
gap: var(--ha-space-2);
}
@media (max-width: 600px) { @media (max-width: 600px) {
:host { :host {
padding: var(--ha-space-2); --spacing: 8px;
} }
} }
`; `;
} }
``` ```
### View Transitions
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
**Core Resources:**
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-base-duration` (350ms, respects `prefers-reduced-motion`)
**Implementation Guidelines:**
1. Always use `withViewTransition()` wrapper for automatic fallback
2. Keep transitions simple (subtle crossfades and fades work best)
3. Use `--ha-animation-base-duration` CSS variable for consistent timing
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
**Default Root Transition:**
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
**Important Constraints:**
- Each `view-transition-name` must be unique at any given time
- Only one view transition can run at a time
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
**Current Usage & Planned Applications:**
- Launch screen fade out (implemented)
- Automation sidebar transitions (planned - #27238)
- More info dialog content changes (planned - #27672)
- Toolbar navigation, ha-spinner transitions (planned)
**Specification & Documentation:**
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
### Performance Best Practices ### Performance Best Practices
- **Code split**: Split code at the panel/dialog level - **Code split**: Split code at the panel/dialog level
@@ -248,9 +195,8 @@ For browser support, API details, and current specifications, refer to these aut
**Available Dialog Types:** **Available Dialog Types:**
- `ha-wa-dialog` - Preferred for new dialogs (Web Awesome based) - `ha-md-dialog` - Preferred for new code (Material Design 3)
- `ha-md-dialog` - Material Design 3 dialog component - `ha-dialog` - Legacy component still widely used
- `ha-dialog` - Legacy component (still widely used)
**Opening Dialogs (Fire Event Pattern - Recommended):** **Opening Dialogs (Fire Event Pattern - Recommended):**
@@ -265,45 +211,15 @@ fireEvent(this, "show-dialog", {
**Dialog Implementation Requirements:** **Dialog Implementation Requirements:**
- Implement `HassDialog<T>` interface - Implement `HassDialog<T>` interface
- Use `@state() private _open = false` to control dialog visibility - Use `createCloseHeading()` for standard headers
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()` - Import `haStyleDialog` for consistent styling
- Return `nothing` when no params (loading state) - Return `nothing` when no params (loading state)
- Fire `dialog-closed` event in `_dialogClosed()` handler - Fire `dialog-closed` event when closing
- Use `header-title` attribute for simple titles - Add `dialogInitialFocus` for accessibility
- Use `header-subtitle` attribute for simple subtitles
- Use slots for custom content where the standard attributes are not enough
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
**Dialog Sizing:** ````
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
- Custom sizing is NOT recommended - use the standard width presets
- Example: `<ha-wa-dialog width="small">` for alert/confirmation dialogs
**Button Appearance Guidelines:**
- **Primary action buttons**: Default appearance (no appearance attribute) or omit for standard styling
- **Secondary action buttons**: Use `appearance="plain"` for cancel/dismiss actions
- **Destructive actions**: Use `appearance="filled"` for delete/remove operations (combined with appropriate semantic styling)
- **Button sizes**: Use `size="small"` (32px height) or default/medium (40px height)
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
**Recent Examples:**
See these files for current patterns:
- `src/panels/config/repairs/dialog-repairs-issue.ts`
- `src/dialogs/restart/dialog-restart.ts`
- `src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts`
**Gallery Documentation:**
- `gallery/src/pages/components/ha-wa-dialog.markdown`
- `gallery/src/pages/components/ha-dialogs.markdown`
### Form Component (ha-form) ### Form Component (ha-form)
- Schema-driven using `HaFormSchema[]` - Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors - Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display - Built-in validation with error display
@@ -319,11 +235,7 @@ See these files for current patterns:
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)} .computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form> ></ha-form>
``` ````
**Gallery Documentation:**
- `gallery/src/pages/components/ha-form.markdown`
### Alert Component (ha-alert) ### Alert Component (ha-alert)
@@ -337,35 +249,6 @@ See these files for current patterns:
<ha-alert alert-type="success" dismissable>Success message</ha-alert> <ha-alert alert-type="success" dismissable>Success message</ha-alert>
``` ```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-alert.markdown`
### Keyboard Shortcuts (ShortcutManager)
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
**Key Features:**
- Automatically blocks shortcuts when input fields are focused
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
**Implementation:**
- **Class definition**: `src/common/keyboard/shortcuts.ts`
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
### Tooltip Component (ha-tooltip)
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
**Implementation:**
- **Component definition**: `src/components/ha-tooltip.ts`
- **Usage example**: `src/components/ha-label.ts`
- **Gallery documentation**: `gallery/src/pages/components/ha-tooltip.markdown`
## Common Patterns ## Common Patterns
### Creating a Panel ### Creating a Panel
@@ -406,19 +289,11 @@ export class DialogMyFeature
@state() @state()
private _params?: MyDialogParams; private _params?: MyDialogParams;
@state()
private _open = false;
public async showDialog(params: MyDialogParams): Promise<void> { public async showDialog(params: MyDialogParams): Promise<void> {
this._params = params; this._params = params;
this._open = true;
} }
public closeDialog(): void { public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined; this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@@ -429,27 +304,23 @@ export class DialogMyFeature
} }
return html` return html`
<ha-wa-dialog <ha-dialog
.hass=${this.hass} open
.open=${this._open} @closed=${this.closeDialog}
header-title=${this._params.title} .heading=${createCloseHeading(this.hass, this._params.title)}
header-subtitle=${this._params.subtitle}
@closed=${this._dialogClosed}
> >
<p>Dialog content</p> <!-- Dialog content -->
<ha-dialog-footer slot="footer"> <ha-button
<ha-button appearance="plain"
slot="secondaryAction" @click=${this.closeDialog}
appearance="plain" slot="secondaryAction"
@click=${this.closeDialog} >
> ${this.hass.localize("ui.common.cancel")}
${this.hass.localize("ui.common.cancel")} </ha-button>
</ha-button> <ha-button @click=${this._submit} slot="primaryAction">
<ha-button slot="primaryAction" @click=${this._submit}> ${this.hass.localize("ui.common.save")}
${this.hass.localize("ui.common.save")} </ha-button>
</ha-button> </ha-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`; `;
} }

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Send bundle stats and build information to RelativeCI - name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@feb19ddc698445db27401f1490f6ac182da0816f # v3.2.0 uses: relative-ci/agent-action@8504826a02078b05756e4c07e380023cc2c4274a # v3.1.0
with: with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }} key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }} token: ${{ github.token }}

View File

@@ -55,7 +55,7 @@ jobs:
script/release script/release
- name: Upload release assets - name: Upload release assets
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
with: with:
files: | files: |
dist/*.whl dist/*.whl
@@ -108,7 +108,7 @@ jobs:
- name: Tar folder - name: Tar folder
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist . run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
- name: Upload release asset - name: Upload release asset
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
with: with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
@@ -137,6 +137,6 @@ jobs:
- name: Tar folder - name: Tar folder
run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build . run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build .
- name: Upload release asset - name: Upload release asset
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
with: with:
files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.11.0.cjs yarnPath: .yarn/releases/yarn-4.10.3.cjs

View File

@@ -1 +0,0 @@
.github/copilot-instructions.md

View File

@@ -260,6 +260,7 @@ const createRspackConfig = ({
), ),
}, },
experiments: { experiments: {
layers: true,
outputModule: true, outputModule: true,
}, },
}; };

View File

@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-card"; import "../../../../src/components/ha-card";
import "../../../../src/components/ha-yaml-editor"; import "../../../../src/components/ha-yaml-editor";
import type { LegacyTrigger } from "../../../../src/data/automation"; import type { Trigger } from "../../../../src/data/automation";
import { describeTrigger } from "../../../../src/data/automation_i18n"; import { describeTrigger } from "../../../../src/data/automation_i18n";
import { getEntity } from "../../../../src/fake_data/entity"; import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";
@@ -66,7 +66,7 @@ const triggers = [
}, },
]; ];
const initialTrigger: LegacyTrigger = { const initialTrigger: Trigger = {
trigger: "state", trigger: "state",
entity_id: "light.kitchen", entity_id: "light.kitchen",
}; };

View File

@@ -1,55 +0,0 @@
---
title: Dropdown
---
# Dropdown `<ha-dropdown>`
## Implementation
A compact, accessible dropdown menu for choosing actions or settings. `ha-dropdown` supports composed menu items (`<ha-dropdown-item>`) for icons, submenus, checkboxes, disabled entries, and destructive variants. Use composition with `slot="trigger"` to control the trigger button and use `<ha-dropdown-item>` for rich item content.
### Example usage (composition)
```html
<ha-dropdown open>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>
<ha-svg-icon .path="mdiContentCut" slot="icon"></ha-svg-icon>
Cut
</ha-dropdown-item>
<ha-dropdown-item>
<ha-svg-icon .path="mdiContentCopy" slot="icon"></ha-svg-icon>
Copy
</ha-dropdown-item>
<ha-dropdown-item disabled>
<ha-svg-icon .path="mdiContentPaste" slot="icon"></ha-svg-icon>
Paste
</ha-dropdown-item>
<ha-dropdown-item>
Show images
<ha-dropdown-item slot="submenu" value="show-all-images"
>Show all images</ha-dropdown-item
>
<ha-dropdown-item slot="submenu" value="show-thumbnails"
>Show thumbnails</ha-dropdown-item
>
</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked>Emoji shortcuts</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked>Word wrap</ha-dropdown-item>
<ha-dropdown-item variant="danger">
<ha-svg-icon .path="mdiDelete" slot="icon"></ha-svg-icon>
Delete
</ha-dropdown-item>
</ha-dropdown>
```
### API
This component is based on the webawesome dropdown component.
Check the [webawesome documentation](https://webawesome.com/docs/components/dropdown/) for more details.

View File

@@ -1,133 +0,0 @@
import "@home-assistant/webawesome/dist/components/button/button";
import "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import "@home-assistant/webawesome/dist/components/icon/icon";
import "@home-assistant/webawesome/dist/components/popup/popup";
import {
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
mdiDelete,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dropdown";
import "../../../../src/components/ha-dropdown-item";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-svg-icon";
@customElement("demo-components-ha-dropdown")
export class DemoHaDropdown extends LitElement {
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-button in ${mode}">
<div class="card-content">
<ha-dropdown open>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>
<ha-svg-icon
.path=${mdiContentCut}
slot="icon"
></ha-svg-icon>
Cut
</ha-dropdown-item>
<ha-dropdown-item>
<ha-svg-icon
.path=${mdiContentCopy}
slot="icon"
></ha-svg-icon>
Copy
</ha-dropdown-item>
<ha-dropdown-item disabled>
<ha-svg-icon
.path=${mdiContentPaste}
slot="icon"
></ha-svg-icon>
Paste
</ha-dropdown-item>
<ha-dropdown-item>
Show images
<ha-dropdown-item slot="submenu" value="show-all-images"
>Show All Images</ha-dropdown-item
>
<ha-dropdown-item slot="submenu" value="show-thumbnails"
>Show Thumbnails</ha-dropdown-item
>
</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked
>Emoji Shortcuts</ha-dropdown-item
>
<ha-dropdown-item type="checkbox" checked
>Word Wrap</ha-dropdown-item
>
<ha-dropdown-item variant="danger">
<ha-svg-icon .path=${mdiDelete} slot="icon"></ha-svg-icon>
Delete
</ha-dropdown-item>
</ha-dropdown>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
.button {
padding: unset;
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.card-content div {
display: flex;
gap: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-dropdown": DemoHaDropdown;
}
}

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19", "@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19", "@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19", "@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0", "@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9", "@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6", "@lit-labs/observers": "2.0.6",
@@ -81,7 +81,7 @@
"@material/mwc-top-app-bar": "0.27.0", "@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0", "@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0", "@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
"@material/web": "2.4.1", "@material/web": "2.4.0",
"@mdi/js": "7.4.47", "@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47", "@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-indentation-markers": "6.5.3",
@@ -89,8 +89,8 @@
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1", "@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0", "@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.9.5", "@vaadin/combo-box": "24.9.2",
"@vaadin/vaadin-themable-mixin": "24.9.5", "@vaadin/vaadin-themable-mixin": "24.9.2",
"@vibrant/color": "4.0.0", "@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0", "@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10", "@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -111,7 +111,7 @@
"fuse.js": "7.1.0", "fuse.js": "7.1.0",
"google-timezones-json": "1.2.0", "google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2", "gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.14", "hls.js": "1.6.13",
"home-assistant-js-websocket": "9.5.0", "home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2", "idb-keyval": "6.2.2",
"intl-messageformat": "10.7.18", "intl-messageformat": "10.7.18",
@@ -122,7 +122,7 @@
"lit": "3.3.1", "lit": "3.3.1",
"lit-html": "3.3.1", "lit-html": "3.3.1",
"luxon": "3.7.2", "luxon": "3.7.2",
"marked": "17.0.0", "marked": "16.4.1",
"memoize-one": "6.0.0", "memoize-one": "6.0.0",
"node-vibrant": "4.0.3", "node-vibrant": "4.0.3",
"object-hash": "3.0.0", "object-hash": "3.0.0",
@@ -152,13 +152,13 @@
"@babel/helper-define-polyfill-provider": "0.6.5", "@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.5", "@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5", "@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.6", "@bundle-stats/plugin-webpack-filter": "4.21.5",
"@lokalise/node-api": "15.3.1", "@lokalise/node-api": "15.3.1",
"@octokit/auth-oauth-device": "8.0.3", "@octokit/auth-oauth-device": "8.0.2",
"@octokit/plugin-retry": "8.0.3", "@octokit/plugin-retry": "8.0.2",
"@octokit/rest": "22.0.1", "@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.3.8", "@rsdoctor/rspack-plugin": "1.3.4",
"@rspack/core": "1.6.1", "@rspack/core": "1.5.8",
"@rspack/dev-server": "1.1.4", "@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22", "@types/chromecast-caf-receiver": "6.0.22",
@@ -178,12 +178,12 @@
"@types/tar": "6.1.13", "@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39", "@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29", "@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.8", "@vitest/coverage-v8": "4.0.3",
"babel-loader": "10.0.0", "babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0", "babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3", "browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1", "del": "8.0.1",
"eslint": "9.39.1", "eslint": "9.38.0",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10", "eslint-import-resolver-webpack": "0.13.10",
@@ -194,14 +194,14 @@
"eslint-plugin-wc": "3.0.2", "eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0", "fancy-log": "2.0.0",
"fs-extra": "11.3.2", "fs-extra": "11.3.2",
"glob": "12.0.0", "glob": "11.0.3",
"gulp": "5.0.1", "gulp": "5.0.1",
"gulp-brotli": "3.0.0", "gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0", "gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0", "gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0", "html-minifier-terser": "7.2.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "27.1.0", "jsdom": "27.0.1",
"jszip": "3.10.1", "jszip": "3.10.1",
"lint-staged": "16.2.6", "lint-staged": "16.2.6",
"lit-analyzer": "2.0.3", "lit-analyzer": "2.0.3",
@@ -213,13 +213,13 @@
"rspack-manifest-plugin": "5.1.0", "rspack-manifest-plugin": "5.1.0",
"serve": "14.2.5", "serve": "14.2.5",
"sinon": "21.0.0", "sinon": "21.0.0",
"tar": "7.5.2", "tar": "7.5.1",
"terser-webpack-plugin": "5.3.14", "terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.46.3", "typescript-eslint": "8.46.2",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.8", "vitest": "4.0.3",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0", "webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -231,12 +231,11 @@
"clean-css": "5.3.3", "clean-css": "5.3.3",
"@lit/reactive-element": "2.1.1", "@lit/reactive-element": "2.1.1",
"@fullcalendar/daygrid": "6.1.19", "@fullcalendar/daygrid": "6.1.19",
"globals": "16.5.0", "globals": "16.4.0",
"tslib": "2.8.1", "tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch", "@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
"glob@^10.2.2": "^10.5.0"
}, },
"packageManager": "yarn@4.11.0", "packageManager": "yarn@4.10.3",
"volta": { "volta": {
"node": "22.21.1" "node": "22.21.1"
} }

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20251029.0" version = "20251105.1"
license = "Apache-2.0" license = "Apache-2.0"
license-files = ["LICENSE*"] license-files = ["LICENSE*"]
description = "The Home Assistant frontend" description = "The Home Assistant frontend"

View File

@@ -1,53 +0,0 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
export interface AreasFloorHierarchy {
floors: {
id: string;
areas: string[];
}[];
areas: string[];
}
export const getAreasFloorHierarchy = (
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[]
): AreasFloorHierarchy => {
const floorAreas = new Map<string, string[]>();
const unassignedAreas: string[] = [];
for (const area of areas) {
if (area.floor_id) {
if (!floorAreas.has(area.floor_id)) {
floorAreas.set(area.floor_id, []);
}
floorAreas.get(area.floor_id)!.push(area.area_id);
} else {
unassignedAreas.push(area.area_id);
}
}
const hierarchy: AreasFloorHierarchy = {
floors: floors.map((floor) => ({
id: floor.floor_id,
areas: floorAreas.get(floor.floor_id) || [],
})),
areas: unassignedAreas,
};
return hierarchy;
};
export const getAreasOrder = (hierarchy: AreasFloorHierarchy): string[] => {
const order: string[] = [];
for (const floor of hierarchy.floors) {
order.push(...floor.areas);
}
order.push(...hierarchy.areas);
return order;
};
export const getFloorOrder = (hierarchy: AreasFloorHierarchy): string[] =>
hierarchy.floors.map((floor) => floor.id);

View File

@@ -1,36 +0,0 @@
import type {
Condition,
TimeCondition,
} from "../../panels/lovelace/common/validate-condition";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
}, []);
}
/**
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: Condition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(c.conditions));
}
if (c.condition === "time") {
array.push(c);
}
return array;
}, []);
}

View File

@@ -1,89 +0,0 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type { Condition } from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
/** Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
* Values exceeding this will overflow and execute immediately
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value
*/
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* Helper to setup media query listeners for conditional visibility
*/
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const mediaQueries = extractMediaQueries(conditions);
if (mediaQueries.length === 0) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
});
addListener(unsub);
});
}
/**
* Helper to setup time-based listeners for conditional visibility
*/
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const timeConditions = extractTimeConditions(conditions);
if (timeConditions.length === 0) return;
timeConditions.forEach((timeCondition) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scheduleUpdate = () => {
const delay = calculateNextTimeUpdate(hass, timeCondition);
if (delay === undefined) return;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
scheduleUpdate();
}, cappedDelay);
};
// Register cleanup function once, outside of scheduleUpdate
addListener(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
});
scheduleUpdate();
});
}

View File

@@ -1,73 +0,0 @@
import { TZDate } from "@date-fns/tz";
import {
startOfDay,
addDays,
addMinutes,
differenceInMilliseconds,
} from "date-fns";
import type { HomeAssistant } from "../../types";
import { TimeZone } from "../../data/translation";
import { parseTimeString } from "../datetime/check_time";
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
/**
* Calculate milliseconds until next time boundary for a time condition
* @param hass Home Assistant object
* @param timeCondition Time condition to calculate next update for
* @returns Milliseconds until next boundary, or undefined if no boundaries
*/
export function calculateNextTimeUpdate(
hass: HomeAssistant,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): number | undefined {
const timezone =
hass.locale.time_zone === TimeZone.server
? hass.config.time_zone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new TZDate(new Date(), timezone);
const updates: Date[] = [];
// Calculate next occurrence of after time
if (after) {
let afterDate = parseTimeString(after, timezone);
if (afterDate <= now) {
// If time has passed today, schedule for tomorrow
afterDate = addDays(afterDate, 1);
}
updates.push(afterDate);
}
// Calculate next occurrence of before time
if (before) {
let beforeDate = parseTimeString(before, timezone);
if (beforeDate <= now) {
// If time has passed today, schedule for tomorrow
beforeDate = addDays(beforeDate, 1);
}
updates.push(beforeDate);
}
// If weekdays are specified, check for midnight (weekday transition)
if (weekdays && weekdays.length > 0 && weekdays.length < 7) {
// Calculate next midnight using startOfDay + addDays
const tomorrow = addDays(now, 1);
const midnight = startOfDay(tomorrow);
updates.push(midnight);
}
if (updates.length === 0) {
return undefined;
}
// Find the soonest update time
const nextUpdate = updates.reduce((soonest, current) =>
current < soonest ? current : soonest
);
// Add 1 minute buffer to ensure we're past the boundary
const updateWithBuffer = addMinutes(nextUpdate, 1);
// Calculate difference in milliseconds
return differenceInMilliseconds(updateWithBuffer, now);
}

View File

@@ -1,131 +0,0 @@
import { TZDate } from "@date-fns/tz";
import { isBefore, isAfter, isWithinInterval } from "date-fns";
import type { HomeAssistant } from "../../types";
import { TimeZone } from "../../data/translation";
import { WEEKDAY_MAP } from "./weekday";
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
/**
* Validate a time string format and value ranges without creating Date objects
* @param timeString Time string to validate (HH:MM or HH:MM:SS)
* @returns true if valid, false otherwise
*/
export function isValidTimeString(timeString: string): boolean {
// Reject empty strings
if (!timeString || timeString.trim() === "") {
return false;
}
const parts = timeString.split(":");
if (parts.length < 2 || parts.length > 3) {
return false;
}
// Ensure each part contains only digits (and optional leading zeros)
// This prevents "8:00 AM" from passing validation
if (!parts.every((part) => /^\d+$/.test(part))) {
return false;
}
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
return false;
}
return (
hours >= 0 &&
hours <= 23 &&
minutes >= 0 &&
minutes <= 59 &&
seconds >= 0 &&
seconds <= 59
);
}
/**
* Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone
*
* Note: This function assumes the time string has already been validated by
* isValidTimeString() at configuration time. It does not re-validate at runtime
* for consistency with other condition types (screen, user, location, etc.)
*
* @param timeString The time string to parse (must be pre-validated)
* @param timezone The timezone to use
* @returns The Date object
*/
export const parseTimeString = (timeString: string, timezone: string): Date => {
const parts = timeString.split(":");
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
const now = new TZDate(new Date(), timezone);
const dateWithTime = new TZDate(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes,
seconds,
0,
timezone
);
return new Date(dateWithTime.getTime());
};
/**
* Check if the current time matches the time condition (after/before/weekday)
* @param hass Home Assistant object
* @param timeCondition Time condition to check
* @returns true if current time matches the condition
*/
export const checkTimeInRange = (
hass: HomeAssistant,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): boolean => {
const timezone =
hass.locale.time_zone === TimeZone.server
? hass.config.time_zone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new TZDate(new Date(), timezone);
// Check weekday condition
if (weekdays && weekdays.length > 0) {
const currentWeekday = WEEKDAY_MAP[now.getDay()];
if (!weekdays.includes(currentWeekday)) {
return false;
}
}
// Check time conditions
if (!after && !before) {
return true;
}
const afterDate = after ? parseTimeString(after, timezone) : undefined;
const beforeDate = before ? parseTimeString(before, timezone) : undefined;
if (afterDate && beforeDate) {
if (isBefore(beforeDate, afterDate)) {
// Crosses midnight (e.g., 22:00 to 06:00)
return !isBefore(now, afterDate) || !isAfter(now, beforeDate);
}
return isWithinInterval(now, { start: afterDate, end: beforeDate });
}
if (afterDate) {
return !isBefore(now, afterDate);
}
if (beforeDate) {
return !isAfter(now, beforeDate);
}
return true;
};

View File

@@ -1,7 +1,18 @@
import { getWeekStartByLocale } from "weekstart"; import { getWeekStartByLocale } from "weekstart";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import { FirstWeekday } from "../../data/translation"; import { FirstWeekday } from "../../data/translation";
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
export const weekdays = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const;
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => { export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
if (locale.first_weekday === FirstWeekday.language) { if (locale.first_weekday === FirstWeekday.language) {
@@ -12,12 +23,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
} }
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex; return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
} }
return WEEKDAYS_LONG.includes(locale.first_weekday) return weekdays.includes(locale.first_weekday)
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex) ? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
: 1; : 1;
}; };
export const firstWeekday = (locale: FrontendLocaleData) => { export const firstWeekday = (locale: FrontendLocaleData) => {
const index = firstWeekdayIndex(locale); const index = firstWeekdayIndex(locale);
return WEEKDAYS_LONG[index]; return weekdays[index];
}; };

View File

@@ -1,59 +0,0 @@
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type WeekdayShort =
| "sun"
| "mon"
| "tue"
| "wed"
| "thu"
| "fri"
| "sat";
export type WeekdayLong =
| "sunday"
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday";
export const WEEKDAYS_SHORT = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
] as const satisfies readonly WeekdayShort[];
export const WEEKDAYS_LONG = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const satisfies readonly WeekdayLong[];
export const WEEKDAY_MAP = {
0: "sun",
1: "mon",
2: "tue",
3: "wed",
4: "thu",
5: "fri",
6: "sat",
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
export const WEEKDAY_SHORT_TO_LONG = {
sun: "sunday",
mon: "monday",
tue: "tuesday",
wed: "wednesday",
thu: "thursday",
fri: "friday",
sat: "saturday",
} as const satisfies Record<WeekdayShort, WeekdayLong>;

View File

@@ -1,6 +1,5 @@
import type { ThemeVars } from "../../data/ws-themes"; import type { ThemeVars } from "../../data/ws-themes";
import { darkColorVariables } from "../../resources/theme/color"; import { darkColorVariables } from "../../resources/theme/color";
import { darkSemanticVariables } from "../../resources/theme/semantic.globals";
import { derivedStyles } from "../../resources/theme/theme"; import { derivedStyles } from "../../resources/theme/theme";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { import {
@@ -53,7 +52,7 @@ export const applyThemesOnElement = (
if (themeToApply && darkMode) { if (themeToApply && darkMode) {
cacheKey = `${cacheKey}__dark`; cacheKey = `${cacheKey}__dark`;
themeRules = { ...darkSemanticVariables, ...darkColorVariables }; themeRules = { ...darkColorVariables };
} }
if (themeToApply === "default") { if (themeToApply === "default") {

View File

@@ -1,67 +0,0 @@
import { tinykeys } from "tinykeys";
import { canOverrideAlphanumericInput } from "../dom/can-override-input";
/**
* A function to handle a keyboard shortcut.
*/
export type ShortcutHandler = (event: KeyboardEvent) => void;
/**
* Configuration for a keyboard shortcut.
*/
export interface ShortcutConfig {
handler: ShortcutHandler;
/**
* If true, allows shortcuts even when text is selected.
* Default is false to avoid interrupting copy/paste.
*/
allowWhenTextSelected?: boolean;
}
/**
* Register keyboard shortcuts using tinykeys.
* Automatically blocks shortcuts in input fields and during text selection.
*/
function registerShortcuts(
shortcuts: Record<string, ShortcutConfig>
): () => void {
const wrappedShortcuts: Record<string, ShortcutHandler> = {};
Object.entries(shortcuts).forEach(([key, config]) => {
wrappedShortcuts[key] = (event: KeyboardEvent) => {
if (!canOverrideAlphanumericInput(event.composedPath())) {
return;
}
if (!config.allowWhenTextSelected && window.getSelection()?.toString()) {
return;
}
config.handler(event);
};
});
return tinykeys(window, wrappedShortcuts);
}
/**
* Manages keyboard shortcuts registration and cleanup.
*/
export class ShortcutManager {
private _disposer?: () => void;
/**
* Register keyboard shortcuts.
* Uses tinykeys syntax: https://github.com/jamiebuilds/tinykeys#usage
*/
public add(shortcuts: Record<string, ShortcutConfig>) {
this._disposer?.();
this._disposer = registerShortcuts(shortcuts);
}
/**
* Remove all registered shortcuts.
*/
public remove() {
this._disposer?.();
this._disposer = undefined;
}
}

View File

@@ -1,36 +0,0 @@
/**
* Parses a CSS duration string (e.g., "300ms", "3s") and returns the duration in milliseconds.
*
* @param duration - A CSS duration string (e.g., "300ms", "3s", "0.5s")
* @returns The duration in milliseconds, or 0 if the input is invalid
*
* @example
* parseAnimationDuration("300ms") // Returns 300
* parseAnimationDuration("3s") // Returns 3000
* parseAnimationDuration("0.5s") // Returns 500
* parseAnimationDuration("invalid") // Returns 0
*/
export const parseAnimationDuration = (duration: string): number => {
const trimmed = duration.trim();
let value: number;
let multiplier: number;
if (trimmed.endsWith("ms")) {
value = parseFloat(trimmed.slice(0, -2));
multiplier = 1;
} else if (trimmed.endsWith("s")) {
value = parseFloat(trimmed.slice(0, -1));
multiplier = 1000;
} else {
// No recognized unit, try parsing as number (assume ms)
value = parseFloat(trimmed);
multiplier = 1;
}
if (!isFinite(value) || value < 0) {
return 0;
}
return value * multiplier;
};

View File

@@ -119,8 +119,8 @@ type Thresholds = Record<
>; >;
export const DEFAULT_THRESHOLDS: Thresholds = { export const DEFAULT_THRESHOLDS: Thresholds = {
second: 59, // seconds to minute second: 45, // seconds to minute
minute: 59, // minutes to hour minute: 45, // minutes to hour
hour: 22, // hour to day hour: 22, // hour to day
day: 5, // day to week day: 5, // day to week
week: 4, // week to months week: 4, // week to months

View File

@@ -1,30 +0,0 @@
/**
* Executes a callback within a View Transition if supported, otherwise runs it directly.
*
* @param callback - Function to execute. Can be synchronous or return a Promise. The callback will be passed a boolean indicating whether the view transition is available.
* @returns Promise that resolves when the transition completes (or immediately if not supported)
*
* @example
* ```typescript
* // Synchronous callback
* withViewTransition(() => {
* this.large = !this.large;
* });
*
* // Async callback
* await withViewTransition(async () => {
* await this.updateData();
* });
* ```
*/
export const withViewTransition = (
callback: (viewTransitionAvailable: boolean) => void | Promise<void>
): Promise<void> => {
if (document.startViewTransition) {
return document.startViewTransition(() => callback(true)).finished;
}
// Fallback: Execute callback directly without transition
const result = callback(false);
return result instanceof Promise ? result : Promise.resolve();
};

View File

@@ -427,7 +427,6 @@ export class HaChartBase extends LitElement {
...axis.axisPointer?.handle, ...axis.axisPointer?.handle,
show: true, show: true,
}, },
label: { show: false },
}, },
} }
: axis : axis
@@ -597,15 +596,10 @@ export class HaChartBase extends LitElement {
aria: { show: true }, aria: { show: true },
dataZoom: this._getDataZoomConfig(), dataZoom: this._getDataZoomConfig(),
toolbox: { toolbox: {
top: Number.MAX_SAFE_INTEGER, top: Infinity,
left: Number.MAX_SAFE_INTEGER, left: Infinity,
feature: { feature: {
dataZoom: { dataZoom: { show: true, yAxisIndex: false, filterMode: "none" },
show: true,
yAxisIndex: false,
filterMode: "none",
showTitle: false,
},
}, },
iconStyle: { opacity: 0 }, iconStyle: { opacity: 0 },
}, },

View File

@@ -2,10 +2,7 @@ import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts"; import type { GraphSeriesOption } from "echarts/charts";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators"; import { customElement, property, state, query } from "lit/decorators";
import type { import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js"; import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query"; import { listenMediaQuery } from "../../common/dom/media_query";
@@ -19,7 +16,6 @@ import { deepEqual } from "../../common/util/deep-equal";
export interface NetworkNode { export interface NetworkNode {
id: string; id: string;
name?: string; name?: string;
context?: string;
category?: number; category?: number;
value?: number; value?: number;
symbolSize?: number; symbolSize?: number;
@@ -188,30 +184,10 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
layout: physicsEnabled ? "force" : "none", layout: physicsEnabled ? "force" : "none",
draggable: true, draggable: true,
roam: true, roam: true,
roamTrigger: "global",
selectedMode: "single", selectedMode: "single",
label: { label: {
show: showLabels, show: showLabels,
position: "right", position: "right",
formatter: (params: CallbackDataParams) => {
const node = params.data as NetworkNode;
if (node.context) {
return `{primary|${node.name ?? ""}}\n{secondary|${node.context}}`;
}
return node.name ?? "";
},
rich: {
primary: {
fontSize: 12,
},
secondary: {
fontSize: 12,
color: getComputedStyle(document.body).getPropertyValue(
"--secondary-text-color"
),
lineHeight: 16,
},
},
}, },
emphasis: { emphasis: {
focus: isMobile ? "none" : "adjacency", focus: isMobile ? "none" : "adjacency",
@@ -249,7 +225,6 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
({ ({
id: node.id, id: node.id,
name: node.name, name: node.name,
context: node.context,
category: node.category, category: node.category,
value: node.value, value: node.value,
symbolSize: node.symbolSize || 30, symbolSize: node.symbolSize || 30,

View File

@@ -30,7 +30,6 @@ export class HaFilterChip extends FilterChip {
var(--rgb-primary-text-color), var(--rgb-primary-text-color),
0.15 0.15
); );
--_label-text-font: var(--ha-font-family-body);
border-radius: var(--ha-border-radius-md); border-radius: var(--ha-border-radius-md);
} }
`, `,

View File

@@ -62,7 +62,6 @@ class HaDataTableLabels extends LitElement {
@click=${clickAction ? this._labelClicked : undefined} @click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined} @keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""} style=${color ? `--color: ${color}` : ""}
.description=${label.description}
> >
${label?.icon ${label?.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>` ? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`

View File

@@ -298,18 +298,6 @@ export class HaDataTable extends LitElement {
} }
if (properties.has("data")) { if (properties.has("data")) {
// Clean up checked rows that no longer exist in the data
if (this._checkedRows.length) {
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
if (validCheckedRows.length !== this._checkedRows.length) {
this._checkedRows = validCheckedRows;
this._checkedRowsChanged();
}
}
this._checkableRowsCount = this.data.filter( this._checkableRowsCount = this.data.filter(
(row) => row.selectable !== false (row) => row.selectable !== false
).length; ).length;

View File

@@ -197,6 +197,9 @@ export class HaDevicePicker extends LitElement {
const placeholder = const placeholder =
this.placeholder ?? this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder"); this.hass.localize("ui.components.device-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.device-picker.no_match"
);
const valueRenderer = this._valueRenderer(this._configEntryLookup); const valueRenderer = this._valueRenderer(this._configEntryLookup);
@@ -206,10 +209,7 @@ export class HaDevicePicker extends LitElement {
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.searchLabel=${this.searchLabel} .searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.device-picker.no_devices"
)}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
@@ -233,11 +233,6 @@ export class HaDevicePicker extends LitElement {
this.value = value; this.value = value;
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -269,6 +269,9 @@ export class HaEntityPicker extends LitElement {
const placeholder = const placeholder =
this.placeholder ?? this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder"); this.hass.localize("ui.components.entity.entity-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.entity.entity-picker.no_match"
);
return html` return html`
<ha-generic-picker <ha-generic-picker
@@ -279,7 +282,7 @@ export class HaEntityPicker extends LitElement {
.label=${this.label} .label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.searchLabel=${this.searchLabel} .searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${notFoundLabel}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.addButton ? undefined : this.value} .value=${this.addButton ? undefined : this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
@@ -353,11 +356,6 @@ export class HaEntityPicker extends LitElement {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -21,6 +21,7 @@ import "../ha-combo-box-item";
import "../ha-generic-picker"; import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker"; import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button"; import "../ha-icon-button";
import "../ha-input-helper-text";
import type { import type {
PickerComboBoxItem, PickerComboBoxItem,
PickerComboBoxSearchFn, PickerComboBoxSearchFn,
@@ -270,6 +271,7 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) .filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ "); .join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`; const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({ output.push({
@@ -277,6 +279,7 @@ export class HaStatisticPicker extends LitElement {
statistic_id: id, statistic_id: id,
primary, primary,
secondary, secondary,
a11y_label: a11yLabel,
stateObj: stateObj, stateObj: stateObj,
type: "entity", type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"), sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
@@ -455,6 +458,9 @@ export class HaStatisticPicker extends LitElement {
const placeholder = const placeholder =
this.placeholder ?? this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder"); this.hass.localize("ui.components.statistic-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.statistic-picker.no_match"
);
return html` return html`
<ha-generic-picker <ha-generic-picker
@@ -462,10 +468,7 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity} .allowCustomValue=${this.allowCustomEntity}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.statistic-picker.no_statistics"
)}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
@@ -474,7 +477,6 @@ export class HaStatisticPicker extends LitElement {
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn} .searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.helper=${this.helper}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>
@@ -519,11 +521,6 @@ export class HaStatisticPicker extends LitElement {
await this.updateComplete; await this.updateComplete;
await this._picker?.open(); await this._picker?.open();
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.statistic-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -369,10 +369,9 @@ export class HaAreaPicker extends LitElement {
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this.hass.localize(
.emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")} "ui.components.area-picker.no_match"
.disabled=${this.disabled} )}
.required=${this.required}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
@@ -426,11 +425,6 @@ export class HaAreaPicker extends LitElement {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.area-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -17,7 +17,6 @@ import type { HomeAssistant } from "../types";
import { AudioRecorder } from "../util/audio-recorder"; import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
import "./ha-alert"; import "./ha-alert";
import "./ha-markdown";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
@@ -41,11 +40,7 @@ export class HaAssistChat extends LitElement {
@query("#message-input") private _messageInput!: HaTextField; @query("#message-input") private _messageInput!: HaTextField;
@query(".message:last-child") @query("#scroll-container") private _scrollContainer!: HTMLDivElement;
private _lastChatMessage!: LitElement;
@query(".message:last-child img:last-of-type")
private _lastChatMessageImage: HTMLImageElement | undefined;
@state() private _conversation: AssistMessage[] = []; @state() private _conversation: AssistMessage[] = [];
@@ -97,7 +92,10 @@ export class HaAssistChat extends LitElement {
public disconnectedCallback() { public disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this._audioRecorder?.close(); this._audioRecorder?.close();
this._audioRecorder = undefined;
this._unloadAudio(); this._unloadAudio();
this._conversation = [];
this._conversationId = null;
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@@ -114,7 +112,7 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech; const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html` return html`
<div class="messages"> <div class="messages" id="scroll-container">
${controlHA ${controlHA
? nothing ? nothing
: html` : html`
@@ -126,18 +124,11 @@ export class HaAssistChat extends LitElement {
`} `}
<div class="spacer"></div> <div class="spacer"></div>
${this._conversation!.map( ${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html` (message) => html`
<ha-markdown <div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
class="message ${classMap({ `
error: !!message.error,
[message.who]: true,
})}"
breaks
cache
.content=${message.text}
>
</ha-markdown>
`
)} )}
</div> </div>
<div class="input" slot="primaryAction"> <div class="input" slot="primaryAction">
@@ -198,28 +189,12 @@ export class HaAssistChat extends LitElement {
`; `;
} }
private async _scrollMessagesBottom() { private _scrollMessagesBottom() {
const lastChatMessage = this._lastChatMessage; const scrollContainer = this._scrollContainer;
if (!lastChatMessage.hasUpdated) { if (!scrollContainer) {
await lastChatMessage.updateComplete; return;
}
if (
this._lastChatMessageImage &&
!this._lastChatMessageImage.naturalHeight
) {
try {
await this._lastChatMessageImage.decode();
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn("Failed to decode image:", err);
}
}
const isLastMessageFullyVisible =
lastChatMessage.getBoundingClientRect().y <
this.getBoundingClientRect().top + 24;
if (!isLastMessageFullyVisible) {
lastChatMessage.scrollIntoView({ behavior: "smooth", block: "start" });
} }
scrollContainer.scrollTo(0, scrollContainer.scrollHeight);
} }
private _handleKeyUp(ev: KeyboardEvent) { private _handleKeyUp(ev: KeyboardEvent) {
@@ -611,31 +586,42 @@ export class HaAssistChat extends LitElement {
flex: 1; flex: 1;
} }
.message { .message {
white-space: pre-line;
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
clear: both; clear: both;
max-width: -webkit-fill-available;
overflow-wrap: break-word;
scroll-margin-top: 24px;
margin: 8px 0; margin: 8px 0;
padding: 8px; padding: 8px;
border-radius: var(--ha-border-radius-xl); border-radius: var(--ha-border-radius-xl);
} }
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
.message { .message {
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
} }
} }
.message p {
margin: 0;
}
.message p:not(:last-child) {
margin-bottom: 8px;
}
.message.user { .message.user {
margin-left: 24px; margin-left: 24px;
margin-inline-start: 24px; margin-inline-start: 24px;
margin-inline-end: initial; margin-inline-end: initial;
align-self: flex-end; align-self: flex-end;
text-align: right;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
--markdown-link-color: var(--text-primary-color);
background-color: var(--chat-background-color-user, var(--primary-color)); background-color: var(--chat-background-color-user, var(--primary-color));
color: var(--text-primary-color); color: var(--text-primary-color);
direction: var(--direction); direction: var(--direction);
} }
.message.hass { .message.hass {
margin-right: 24px; margin-right: 24px;
margin-inline-end: 24px; margin-inline-end: 24px;
@@ -650,21 +636,20 @@ export class HaAssistChat extends LitElement {
color: var(--primary-text-color); color: var(--primary-text-color);
direction: var(--direction); direction: var(--direction);
} }
.message.user a {
color: var(--text-primary-color);
}
.message.hass a {
color: var(--primary-text-color);
}
.message.error { .message.error {
background-color: var(--error-color); background-color: var(--error-color);
color: var(--text-primary-color); color: var(--text-primary-color);
} }
ha-markdown {
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
--markdown-table-border-color: var(--divider-color);
--markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color);
&:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
}
.bouncer { .bouncer {
width: 48px; width: 48px;
height: 48px; height: 48px;

View File

@@ -94,12 +94,6 @@ export class HaDateInput extends LitElement {
} }
private _keyDown(ev: KeyboardEvent) { private _keyDown(ev: KeyboardEvent) {
if (["Space", "Enter"].includes(ev.code)) {
ev.preventDefault();
ev.stopPropagation();
this._openDialog();
return;
}
if (!this.canClear) { if (!this.canClear) {
return; return;
} }

View File

@@ -90,8 +90,7 @@ export class HaDialog extends DialogBase {
} }
.mdc-dialog__actions { .mdc-dialog__actions {
justify-content: var(--justify-action-buttons, flex-end); justify-content: var(--justify-action-buttons, flex-end);
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4) padding: 12px 16px 16px 16px;
var(--ha-space-4);
} }
.mdc-dialog__actions span:nth-child(1) { .mdc-dialog__actions span:nth-child(1) {
flex: var(--secondary-action-button-flex, unset); flex: var(--secondary-action-button-flex, unset);
@@ -101,24 +100,22 @@ export class HaDialog extends DialogBase {
} }
.mdc-dialog__container { .mdc-dialog__container {
align-items: var(--vertical-align-dialog, center); align-items: var(--vertical-align-dialog, center);
padding: var(--dialog-container-padding, var(--ha-space-0));
} }
.mdc-dialog__title { .mdc-dialog__title {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-0) padding: 16px 16px 0 16px;
var(--ha-space-4);
} }
.mdc-dialog__title:has(span) { .mdc-dialog__title:has(span) {
padding: var(--ha-space-3) var(--ha-space-3) var(--ha-space-0); padding: 12px 12px 0;
} }
.mdc-dialog__title::before { .mdc-dialog__title::before {
content: unset; content: unset;
} }
.mdc-dialog .mdc-dialog__content { .mdc-dialog .mdc-dialog__content {
position: var(--dialog-content-position, relative); position: var(--dialog-content-position, relative);
padding: var(--dialog-content-padding, var(--ha-space-6)); padding: var(--dialog-content-padding, 24px);
} }
:host([hideactions]) .mdc-dialog .mdc-dialog__content { :host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: var(--dialog-content-padding, var(--ha-space-6)); padding-bottom: var(--dialog-content-padding, 24px);
} }
.mdc-dialog .mdc-dialog__surface { .mdc-dialog .mdc-dialog__surface {
position: var(--dialog-surface-position, relative); position: var(--dialog-surface-position, relative);
@@ -136,7 +133,7 @@ export class HaDialog extends DialogBase {
--ha-dialog-surface-background, --ha-dialog-surface-background,
var(--mdc-theme-surface, #fff) var(--mdc-theme-surface, #fff)
); );
padding: var(--dialog-surface-padding, var(--ha-space-0)); padding: var(--dialog-surface-padding);
} }
:host([flexContent]) .mdc-dialog .mdc-dialog__content { :host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex; display: flex;
@@ -153,22 +150,22 @@ export class HaDialog extends DialogBase {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
display: block; display: block;
padding-left: var(--ha-space-1); padding-left: 4px;
padding-right: var(--ha-space-1); padding-right: 4px;
margin-right: var(--ha-space-3); margin-right: 12px;
margin-inline-end: var(--ha-space-3); margin-inline-end: 12px;
margin-inline-start: initial; margin-inline-start: initial;
} }
.header_button { .header_button {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
inset-inline-start: initial; inset-inline-start: initial;
inset-inline-end: calc(var(--ha-space-3) * -1); inset-inline-end: -12px;
direction: var(--direction); direction: var(--direction);
} }
.dialog-actions { .dialog-actions {
inset-inline-start: initial !important; inset-inline-start: initial !important;
inset-inline-end: var(--ha-space-0) !important; inset-inline-end: 0px !important;
direction: var(--direction); direction: var(--direction);
} }
`, `,

View File

@@ -1,41 +0,0 @@
import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item";
import { css, type CSSResultGroup } from "lit";
import { customElement } from "lit/decorators";
/**
* Home Assistant dropdown item component
*
* @element ha-dropdown-item
* @extends {DropdownItem}
*
* @summary
* A stylable dropdown item component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown item.
*
*/
@customElement("ha-dropdown-item")
export class HaDropdownItem extends DropdownItem {
static get styles(): CSSResultGroup {
return [
DropdownItem.styles,
css`
:host {
min-height: var(--ha-space-10);
}
#icon ::slotted(*) {
color: var(--ha-color-on-neutral-normal);
}
:host([variant="danger"]) #icon ::slotted(*) {
color: var(--ha-color-on-danger-quiet);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dropdown-item": HaDropdownItem;
}
}

View File

@@ -1,45 +0,0 @@
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
/**
* Home Assistant dropdown component
*
* @element ha-dropdown
* @extends {Dropdown}
*
* @summary
* A stylable dropdown component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown.
*
*/
@customElement("ha-dropdown")
export class HaDropdown extends Dropdown {
@property({ attribute: false }) dropdownTag = "ha-dropdown";
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
static get styles(): CSSResultGroup {
return [
Dropdown.styles,
css`
:host {
font-size: var(--ha-dropdown-font-size, var(--ha-font-size-m));
--wa-color-surface-raised: var(
--card-background-color,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
}
#menu {
padding: var(--ha-space-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dropdown": HaDropdown;
}
}

View File

@@ -109,10 +109,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
.selected=${(this.value || []).includes(label.label_id)} .selected=${(this.value || []).includes(label.label_id)}
hasMeta hasMeta
> >
<ha-label <ha-label style=${color ? `--color: ${color}` : ""}>
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon ${label.icon
? html`<ha-icon ? html`<ha-icon
slot="icon" slot="icon"

View File

@@ -383,9 +383,8 @@ export class HaFloorPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this.hass.localize(
.emptyLabel=${this.hass.localize( "ui.components.floor-picker.no_match"
"ui.components.floor-picker.no_floors"
)} )}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
@@ -445,11 +444,6 @@ export class HaFloorPicker extends LitElement {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.floor-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -25,6 +25,9 @@ import "./ha-svg-icon";
export class HaGenericPicker extends LitElement { export class HaGenericPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@@ -46,11 +49,8 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean }) @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false; public hideClearIcon = false;
@property({ attribute: false }) @property({ attribute: false, type: Array })
public getItems?: ( public getItems?: () => PickerComboBoxItem[];
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -64,11 +64,8 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: false }) @property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string | ((search: string) => string); public notFoundLabel?: string;
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: "popover-placement" }) @property({ attribute: "popover-placement" })
public popoverPlacement: public popoverPlacement:
@@ -88,25 +85,6 @@ export class HaGenericPicker extends LitElement {
/** If set picker shows an add button instead of textbox when value isn't set */ /** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string; @property({ attribute: "add-button-label" }) public addButtonLabel?: string;
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
id: string;
label: string;
}
| "separator"
)[];
@property({ attribute: false }) public sectionTitleFunction?: (listInfo: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => string | undefined;
@property({ attribute: "selected-section" }) public selectedSection?: string;
@query(".container") private _containerElement?: HTMLDivElement; @query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -119,11 +97,6 @@ export class HaGenericPicker extends LitElement {
@state() private _openedNarrow = false; @state() private _openedNarrow = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
private _narrow = false; private _narrow = false;
// helper to set new value after closing picker, to avoid flicker // helper to set new value after closing picker, to avoid flicker
@@ -216,19 +189,16 @@ export class HaGenericPicker extends LitElement {
<ha-picker-combo-box <ha-picker-combo-box
.hass=${this.hass} .hass=${this.hass}
.allowCustomValue=${this.allowCustomValue} .allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel} .label=${this.searchLabel ??
(this.hass?.localize("ui.common.search") || "Search")}
.value=${this.value} .value=${this.value}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer} .rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel} .notFoundLabel=${this.notFoundLabel}
.emptyLabel=${this.emptyLabel}
.getItems=${this.getItems} .getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems} .getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn} .searchFn=${this.searchFn}
.mode=${dialogMode ? "dialog" : "popover"} .mode=${dialogMode ? "dialog" : "popover"}
.sections=${this.sections}
.sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection}
></ha-picker-combo-box> ></ha-picker-combo-box>
`; `;
} }

View File

@@ -60,10 +60,6 @@ class HaHLSPlayer extends LitElement {
private static streamCount = 0; private static streamCount = 0;
private _handleVisibilityChange = () => { private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) { if (document.hidden) {
this._cleanUp(); this._cleanUp();
} else { } else {

View File

@@ -224,9 +224,8 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this.hass.localize(
.emptyLabel=${this.hass.localize( "ui.components.label-picker.no_match"
"ui.components.label-picker.no_labels"
)} )}
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")} .addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
.placeholder=${placeholder} .placeholder=${placeholder}
@@ -289,11 +288,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
fireEvent(this, "change"); fireEvent(this, "change");
}, 0); }, 0);
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.label-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -1,32 +1,17 @@
import type { CSSResultGroup, TemplateResult } from "lit"; import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { uid } from "../common/util/uid";
import "./ha-tooltip";
@customElement("ha-label") @customElement("ha-label")
class HaLabel extends LitElement { class HaLabel extends LitElement {
@property({ type: Boolean, reflect: true }) dense = false; @property({ type: Boolean, reflect: true }) dense = false;
@property({ attribute: "description" })
public description?: string;
private _elementId = "label-" + uid();
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<ha-tooltip <span class="content">
.for=${this._elementId} <slot name="icon"></slot>
.disabled=${!this.description?.trim()} <slot></slot>
> </span>
${this.description}
</ha-tooltip>
<div class="container" .id=${this._elementId}>
<span class="content">
<slot name="icon"></slot>
<slot></slot>
</span>
</div>
`; `;
} }
@@ -51,7 +36,9 @@ class HaLabel extends LitElement {
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);
letter-spacing: 0.1px; letter-spacing: 0.1px;
vertical-align: middle;
height: 32px; height: 32px;
padding: 0 16px;
border-radius: var(--ha-border-radius-xl); border-radius: var(--ha-border-radius-xl);
color: var(--ha-label-text-color); color: var(--ha-label-text-color);
--mdc-icon-size: 12px; --mdc-icon-size: 12px;
@@ -79,23 +66,14 @@ class HaLabel extends LitElement {
display: flex; display: flex;
} }
.container {
display: flex;
position: relative;
height: 100%;
padding: 0 16px;
}
span { span {
display: inline-flex; display: inline-flex;
} }
:host([dense]) { :host([dense]) {
height: 20px; height: 20px;
border-radius: var(--ha-border-radius-md);
}
:host([dense]) .container {
padding: 0 12px; padding: 0 12px;
border-radius: var(--ha-border-radius-md);
} }
:host([dense]) ::slotted([slot="icon"]) { :host([dense]) ::slotted([slot="icon"]) {
margin-right: 4px; margin-right: 4px;

View File

@@ -21,7 +21,6 @@ import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-label-picker"; import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker"; import type { HaLabelPicker } from "./ha-label-picker";
import "./ha-tooltip";
@customElement("ha-labels-picker") @customElement("ha-labels-picker")
export class HaLabelsPicker extends SubscribeMixin(LitElement) { export class HaLabelsPicker extends SubscribeMixin(LitElement) {
@@ -143,17 +142,9 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
const color = label?.color const color = label?.color
? computeCssColor(label.color) ? computeCssColor(label.color)
: undefined; : undefined;
const elementId = "label-" + label.label_id;
return html` return html`
<ha-tooltip
.for=${elementId}
.disabled=${!label?.description?.trim()}
>
${label?.description}
</ha-tooltip>
<ha-input-chip <ha-input-chip
.item=${label} .item=${label}
.id=${elementId}
@remove=${this._removeItem} @remove=${this._removeItem}
@click=${this._openDetail} @click=${this._openDetail}
.disabled=${this.disabled} .disabled=${this.disabled}

View File

@@ -125,10 +125,9 @@ export class HaLanguagePicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
popover-placement="bottom-end" popover-placement="bottom-end"
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this.hass?.localize(
.emptyLabel=${this.hass?.localize( "ui.components.language-picker.no_match"
"ui.components.language-picker.no_languages" )}
) || "No languages available"}
.placeholder=${this.label ?? .placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") || (this.hass?.localize("ui.components.language-picker.language") ||
"Language")} "Language")}
@@ -173,15 +172,6 @@ export class HaLanguagePicker extends LitElement {
this.value = ev.detail.value; this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value }); fireEvent(this, "value-changed", { value: this.value });
} }
private _notFoundLabel = (search: string) => {
const term = html`<b>${search}</b>`;
return this.hass
? this.hass.localize("ui.components.language-picker.no_match", {
term,
})
: html`No languages found for ${term}`;
};
} }
declare global { declare global {

View File

@@ -1,15 +1,11 @@
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { ReactiveElement, render, html } from "lit"; import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
// eslint-disable-next-line import/extensions
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import hash from "object-hash"; import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { renderMarkdown } from "../resources/render-markdown"; import { renderMarkdown } from "../resources/render-markdown";
import { CacheManager } from "../util/cache-manager"; import { CacheManager } from "../util/cache-manager";
const h = (template: ReturnType<typeof unsafeHTML>) => html`${template}`;
const markdownCache = new CacheManager<string>(1000); const markdownCache = new CacheManager<string>(1000);
const _gitHubMarkdownAlerts = { const _gitHubMarkdownAlerts = {
@@ -52,26 +48,18 @@ class HaMarkdownElement extends ReactiveElement {
return this; return this;
} }
private _renderPromise: ReturnType<typeof this._render> = Promise.resolve();
protected update(changedProps) { protected update(changedProps) {
super.update(changedProps); super.update(changedProps);
if (this.content !== undefined) { if (this.content !== undefined) {
this._renderPromise = this._render(); this._render();
} }
} }
protected async getUpdateComplete(): Promise<boolean> {
await super.getUpdateComplete();
await this._renderPromise;
return true;
}
protected willUpdate(_changedProperties: PropertyValues): void { protected willUpdate(_changedProperties: PropertyValues): void {
if (!this.innerHTML && this.cache) { if (!this.innerHTML && this.cache) {
const key = this._computeCacheKey(); const key = this._computeCacheKey();
if (markdownCache.has(key)) { if (markdownCache.has(key)) {
render(markdownCache.get(key)!, this.renderRoot); this.innerHTML = markdownCache.get(key)!;
this._resize(); this._resize();
} }
} }
@@ -87,7 +75,7 @@ class HaMarkdownElement extends ReactiveElement {
} }
private async _render() { private async _render() {
const elements = await renderMarkdown( this.innerHTML = await renderMarkdown(
String(this.content), String(this.content),
{ {
breaks: this.breaks, breaks: this.breaks,
@@ -99,11 +87,6 @@ class HaMarkdownElement extends ReactiveElement {
} }
); );
render(
elements.map((e) => h(unsafeHTML(e))),
this.renderRoot
);
this._resize(); this._resize();
const walker = document.createTreeWalker( const walker = document.createTreeWalker(

View File

@@ -1,12 +1,5 @@
import { import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
css, import { customElement, property } from "lit/decorators";
html,
LitElement,
nothing,
type ReactiveElement,
type CSSResultGroup,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import "./ha-markdown-element"; import "./ha-markdown-element";
@customElement("ha-markdown") @customElement("ha-markdown")
@@ -25,14 +18,6 @@ export class HaMarkdown extends LitElement {
@property({ type: Boolean }) public cache = false; @property({ type: Boolean }) public cache = false;
@query("ha-markdown-element") private _markdownElement!: ReactiveElement;
protected async getUpdateComplete() {
const result = await super.getUpdateComplete();
await this._markdownElement.updateComplete;
return result;
}
protected render() { protected render() {
if (!this.content) { if (!this.content) {
return nothing; return nothing;
@@ -68,46 +53,19 @@ export class HaMarkdown extends LitElement {
margin: var(--ha-space-1) 0; margin: var(--ha-space-1) 0;
} }
a { a {
color: var(--markdown-link-color, var(--primary-color)); color: var(--primary-color);
} }
img { img {
background-color: rgba(10, 10, 10, 0.15);
border-radius: var(--markdown-image-border-radius);
max-width: 100%; max-width: 100%;
min-height: 2lh;
height: auto;
width: auto;
text-indent: 4px;
transition: height 0.2s ease-in-out;
}
p:first-child > img:first-child {
vertical-align: top;
}
p:first-child > img:last-child {
vertical-align: top;
}
ol,
ul {
list-style-position: inside;
padding-inline-start: 0;
}
li {
&:has(input[type="checkbox"]) {
list-style: none;
& > input[type="checkbox"] {
margin-left: 0;
}
}
}
svg {
background-color: var(--markdown-svg-background-color, none);
color: var(--markdown-svg-color, none);
} }
code, code,
pre { pre {
background-color: var(--markdown-code-background-color, none); background-color: var(--markdown-code-background-color, none);
border-radius: var(--ha-border-radius-sm); border-radius: var(--ha-border-radius-sm);
color: var(--markdown-code-text-color, inherit); }
svg {
background-color: var(--markdown-svg-background-color, none);
color: var(--markdown-svg-color, none);
} }
code { code {
font-size: var(--ha-font-size-s); font-size: var(--ha-font-size-s);
@@ -139,24 +97,6 @@ export class HaMarkdown extends LitElement {
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: var(--ha-space-4) 0;
} }
table {
border-collapse: collapse;
display: block;
overflow-x: auto;
}
th {
text-align: start;
}
td,
th {
border: 1px solid var(--markdown-table-border-color, transparent);
padding: 0.25em 0.5em;
}
blockquote {
border-left: 4px solid var(--divider-color);
margin-inline: 0;
padding-inline: 1em;
}
` as CSSResultGroup; ` as CSSResultGroup;
} }

View File

@@ -175,10 +175,10 @@ export class HaMdDialog extends Dialog {
} }
.container { .container {
margin-top: var(--safe-area-inset-top, var(--ha-space-0)); padding-top: var(--safe-area-inset-top);
margin-bottom: var(--safe-area-inset-bottom, var(--ha-space-0)); padding-bottom: var(--safe-area-inset-bottom);
margin-left: var(--safe-area-inset-left, var(--ha-space-0)); padding-left: var(--safe-area-inset-left);
margin-right: var(--safe-area-inset-right, var(--ha-space-0)); padding-right: var(--safe-area-inset-right);
} }
} }
@@ -187,7 +187,7 @@ export class HaMdDialog extends Dialog {
} }
slot[name="actions"]::slotted(*) { slot[name="actions"]::slotted(*) {
padding: var(--ha-space-4); padding: 16px;
} }
.scroller { .scroller {
@@ -195,7 +195,7 @@ export class HaMdDialog extends Dialog {
} }
slot[name="content"]::slotted(*) { slot[name="content"]::slotted(*) {
padding: var(--dialog-content-padding, var(--ha-space-6)); padding: var(--dialog-content-padding, 24px);
} }
.scrim { .scrim {
z-index: 10; /* overlay navigation */ z-index: 10; /* overlay navigation */

View File

@@ -6,7 +6,6 @@ import { fireEvent } from "../common/dom/fire_event";
import { titleCase } from "../common/string/title-case"; import { titleCase } from "../common/string/title-case";
import { fetchConfig } from "../data/lovelace/config/types"; import { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view"; import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getDefaultPanelUrlPath } from "../data/panel";
import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types"; import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-combo-box"; import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box"; import type { HaComboBox } from "./ha-combo-box";
@@ -45,7 +44,7 @@ const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`, path: `/${panel.url_path}`,
icon: panel.icon ?? "mdi:view-dashboard", icon: panel.icon ?? "mdi:view-dashboard",
title: title:
panel.url_path === getDefaultPanelUrlPath(hass) panel.url_path === hass.defaultPanel
? hass.localize("panel.states") ? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) || : hass.localize(`panel.${panel.title}`) ||
panel.title || panel.title ||

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer"; import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js"; import { mdiMagnify } from "@mdi/js";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { import {
@@ -14,12 +14,11 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys"; import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare"; import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaFuse } from "../resources/fuse"; import { HaFuse } from "../resources/fuse";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer"; import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-icon"; import "./ha-icon";
import "./ha-textfield"; import "./ha-textfield";
@@ -28,18 +27,28 @@ import type { HaTextField } from "./ha-textfield";
export interface PickerComboBoxItem { export interface PickerComboBoxItem {
id: string; id: string;
primary: string; primary: string;
a11y_label?: string;
secondary?: string; secondary?: string;
search_labels?: string[]; search_labels?: string[];
sorting_label?: string; sorting_label?: string;
icon_path?: string; icon_path?: string;
icon?: string; icon?: string;
} }
const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
// Hack to force empty label to always display empty value by default in the search field
export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
a11y_label: string;
}
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = ( const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item item
) => html` ) => html`
<ha-combo-box-item type="button" compact> <ha-combo-box-item
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
compact
>
${item.icon ${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>` ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path : item.icon_path
@@ -78,11 +87,8 @@ export class HaPickerComboBox extends LitElement {
@state() private _listScrolled = false; @state() private _listScrolled = false;
@property({ attribute: false }) @property({ attribute: false, type: Array })
public getItems?: ( public getItems?: () => PickerComboBoxItem[];
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -90,45 +96,21 @@ export class HaPickerComboBox extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false }) @property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string | ((search: string) => string); public notFoundLabel?: string;
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: false }) @property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; @property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
id: string;
label: string;
}
| "separator"
)[];
@property({ attribute: false }) public sectionTitleFunction?: (listInfo: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => string | undefined;
@property({ attribute: "selected-section" }) public selectedSection?: string;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
@query("ha-textfield") private _searchFieldElement?: HaTextField; @query("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: (PickerComboBoxItem | string)[] = []; @state() private _items: PickerComboBoxItemWithLabel[] = [];
@state() private _sectionTitle?: string; private _allItems: PickerComboBoxItemWithLabel[] = [];
private _allItems: (PickerComboBoxItem | string)[] = [];
private _selectedItemIndex = -1; private _selectedItemIndex = -1;
@@ -139,8 +121,6 @@ export class HaPickerComboBox extends LitElement {
private _removeKeyboardShortcuts?: () => void; private _removeKeyboardShortcuts?: () => void;
private _search = "";
protected firstUpdated() { protected firstUpdated() {
this._registerKeyboardShortcuts(); this._registerKeyboardShortcuts();
} }
@@ -165,142 +145,74 @@ export class HaPickerComboBox extends LitElement {
"Search"} "Search"}
@input=${this._filterChanged} @input=${this._filterChanged}
></ha-textfield> ></ha-textfield>
${this._renderSectionButtons()}
${this.sections?.length
? html`
<div class="section-title-wrapper">
<div
class="section-title ${!this.selectedSection &&
this._sectionTitle
? "show"
: ""}"
>
${this._sectionTitle}
</div>
</div>
`
: nothing}
<lit-virtualizer <lit-virtualizer
.keyFunction=${this._keyFunction} @scroll=${this._onScrollList}
tabindex="0" tabindex="0"
scroller scroller
.items=${this._items} .items=${this._items}
.renderItem=${this._renderItem} .renderItem=${this._renderItem}
style="min-height: 36px;" style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""} class=${this._listScrolled ? "scrolled" : ""}
@scroll=${this._onScrollList}
@focus=${this._focusList} @focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
> >
</lit-virtualizer> `; </lit-virtualizer> `;
} }
private _renderSectionButtons() { private _defaultNotFoundItem = memoizeOne(
if (!this.sections || this.sections.length === 0) { (
return nothing; label: this["notFoundLabel"],
} localize?: LocalizeFunc
): PickerComboBoxItemWithLabel => ({
id: NO_MATCHING_ITEMS_FOUND_ID,
primary:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
icon_path: mdiMagnify,
a11y_label:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
})
);
return html` private _getAdditionalItems = (searchString?: string) => {
<ha-chip-set class="sections"> const items = this.getAdditionalItems?.(searchString) || [];
${this.sections.map((section) =>
section === "separator"
? html`<div class="separator"></div>`
: html`<ha-filter-chip
@click=${this._toggleSection}
.section-id=${section.id}
.selected=${this.selectedSection === section.id}
.label=${section.label}
>
</ha-filter-chip>`
)}
</ha-chip-set>
`;
}
@eventOptions({ passive: true }) return items.map<PickerComboBoxItemWithLabel>((item) => ({
private _visibilityChanged(ev) { ...item,
if ( a11y_label: item.a11y_label || item.primary,
this._virtualizerElement && }));
this.sectionTitleFunction && };
this.sections?.length
) {
const firstItem = this._virtualizerElement.items[ev.first];
const secondItem = this._virtualizerElement.items[ev.first + 1];
this._sectionTitle = this.sectionTitleFunction({
firstIndex: ev.first,
lastIndex: ev.last,
firstItem: firstItem as PickerComboBoxItem | string,
secondItem: secondItem as PickerComboBoxItem | string,
itemsCount: this._virtualizerElement.items.length,
});
}
}
private _getAdditionalItems = (searchString?: string) => private _getItems = (): PickerComboBoxItemWithLabel[] => {
this.getAdditionalItems?.(searchString) || []; const items = this.getItems ? this.getItems() : [];
private _getItems = () => { const sortedItems = items
let items = [ .map<PickerComboBoxItemWithLabel>((item) => ({
...(this.getItems ...item,
? this.getItems(this._search, this.selectedSection) a11y_label: item.a11y_label || item.primary,
: []), }))
]; .sort((entityA, entityB) =>
if (!this.sections?.length) {
items = items.sort((entityA, entityB) =>
caseInsensitiveStringCompare( caseInsensitiveStringCompare(
(entityA as PickerComboBoxItem).sorting_label!, entityA.sorting_label!,
(entityB as PickerComboBoxItem).sorting_label!, entityB.sorting_label!,
this.hass?.locale.language ?? navigator.language this.hass?.locale.language ?? navigator.language
) )
); );
}
if (!items.length) { if (!sortedItems.length) {
items.push(NO_ITEMS_AVAILABLE_ID); sortedItems.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
);
} }
const additionalItems = this._getAdditionalItems(); const additionalItems = this._getAdditionalItems();
items.push(...additionalItems); sortedItems.push(...additionalItems);
return sortedItems;
if (this.mode === "dialog") {
items.push("padding"); // padding for safe area inset
}
return items;
}; };
private _renderItem = (item: PickerComboBoxItem | string, index: number) => { private _renderItem = (item: PickerComboBoxItem, index: number) => {
if (item === "padding") {
return html`<div class="bottom-padding"></div>`;
}
if (item === NO_ITEMS_AVAILABLE_ID) {
return html`
<div class="combo-box-row">
<ha-combo-box-item type="text" compact>
<ha-svg-icon
slot="start"
.path=${this._search ? mdiMagnify : mdiMinusBoxOutline}
></ha-svg-icon>
<span slot="headline"
>${this._search
? typeof this.notFoundLabel === "function"
? this.notFoundLabel(this._search)
: this.notFoundLabel ||
this.hass?.localize("ui.components.combo-box.no_match") ||
"No matching items found"
: this.emptyLabel ||
this.hass?.localize("ui.components.combo-box.no_items") ||
"No items available"}</span
>
</ha-combo-box-item>
</div>
`;
}
if (typeof item === "string") {
return html`<div class="title">${item}</div>`;
}
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER; const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
return html`<div return html`<div
id=${`list-item-${index}`} id=${`list-item-${index}`}
@@ -309,7 +221,9 @@ export class HaPickerComboBox extends LitElement {
.index=${index} .index=${index}
@click=${this._valueSelected} @click=${this._valueSelected}
> >
${renderer(item, index)} ${item.id === NO_MATCHING_ITEMS_FOUND_ID
? DEFAULT_ROW_RENDERER(item, index)
: renderer(item, index)}
</div>`; </div>`;
}; };
@@ -328,6 +242,10 @@ export class HaPickerComboBox extends LitElement {
const value = (ev.currentTarget as any).value as string; const value = (ev.currentTarget as any).value as string;
const newValue = value?.trim(); const newValue = value?.trim();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
fireEvent(this, "value-changed", { value: newValue }); fireEvent(this, "value-changed", { value: newValue });
}; };
@@ -338,83 +256,51 @@ export class HaPickerComboBox extends LitElement {
private _filterChanged = (ev: Event) => { private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField; const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim(); const searchString = textfield.value.trim();
this._search = searchString;
if (this.sections?.length) { if (!searchString) {
this._items = this._getItems(); this._items = this._allItems;
} else { return;
if (!searchString) {
this._items = this._allItems;
return;
}
const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
const fuse = new HaFuse(
this._allItems as PickerComboBoxItem[],
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = [...this._allItems];
if (results) {
const items: (PickerComboBoxItem | string)[] = results.map(
(result) => result.item
);
if (!items.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
filteredItems = items;
}
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems as PickerComboBoxItem[],
this._allItems as PickerComboBoxItem[]
);
}
this._items = filteredItems as PickerComboBoxItem[];
} }
const index = this._fuseIndex(this._allItems);
const fuse = new HaFuse(
this._allItems,
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = this._allItems as PickerComboBoxItem[];
if (results) {
const items = results.map((result) => result.item);
if (items.length === 0) {
items.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
);
}
const additionalItems = this._getAdditionalItems(searchString);
items.push(...additionalItems);
filteredItems = items;
}
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems,
this._allItems
);
}
this._items = filteredItems as PickerComboBoxItemWithLabel[];
this._selectedItemIndex = -1; this._selectedItemIndex = -1;
if (this._virtualizerElement) { if (this._virtualizerElement) {
this._virtualizerElement.scrollTo(0, 0); this._virtualizerElement.scrollTo(0, 0);
} }
}; };
private _toggleSection(ev: Event) {
ev.stopPropagation();
this._resetSelectedItem();
this._sectionTitle = undefined;
const section = (ev.target as HTMLElement)["section-id"] as string;
if (!section) {
return;
}
if (this.selectedSection === section) {
this.selectedSection = undefined;
} else {
this.selectedSection = section;
}
this._items = this._getItems();
// Reset scroll position when filter changes
if (this._virtualizerElement) {
this._virtualizerElement.scrollToIndex(0);
}
}
private _registerKeyboardShortcuts() { private _registerKeyboardShortcuts() {
this._removeKeyboardShortcuts = tinykeys(this, { this._removeKeyboardShortcuts = tinykeys(this, {
ArrowUp: this._selectPreviousItem, ArrowUp: this._selectPreviousItem,
@@ -458,7 +344,7 @@ export class HaPickerComboBox extends LitElement {
return; return;
} }
if (typeof items[nextIndex] === "string") { if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
// Skip titles, padding and empty search // Skip titles, padding and empty search
if (nextIndex === maxItems) { if (nextIndex === maxItems) {
return; return;
@@ -487,7 +373,7 @@ export class HaPickerComboBox extends LitElement {
return; return;
} }
if (typeof items[nextIndex] === "string") { if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
// Skip titles, padding and empty search // Skip titles, padding and empty search
if (nextIndex === 0) { if (nextIndex === 0) {
return; return;
@@ -509,6 +395,13 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = 0; const nextIndex = 0;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") { if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1; this._selectedItemIndex = nextIndex + 1;
} else { } else {
@@ -526,6 +419,13 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = this._virtualizerElement.items.length - 1; const nextIndex = this._virtualizerElement.items.length - 1;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") { if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1; this._selectedItemIndex = nextIndex - 1;
} else { } else {
@@ -553,7 +453,10 @@ export class HaPickerComboBox extends LitElement {
ev.stopPropagation(); ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem; const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if (this._virtualizerElement?.items.length === 1) { if (
this._virtualizerElement?.items.length === 1 &&
firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
) {
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: firstItem.id, value: firstItem.id,
}); });
@@ -569,7 +472,7 @@ export class HaPickerComboBox extends LitElement {
const item = this._virtualizerElement?.items[ const item = this._virtualizerElement?.items[
this._selectedItemIndex this._selectedItemIndex
] as PickerComboBoxItem; ] as PickerComboBoxItem;
if (item) { if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
fireEvent(this, "value-changed", { value: item.id }); fireEvent(this, "value-changed", { value: item.id });
} }
}; };
@@ -581,9 +484,6 @@ export class HaPickerComboBox extends LitElement {
this._selectedItemIndex = -1; this._selectedItemIndex = -1;
} }
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
static styles = [ static styles = [
haStyleScrollbar, haStyleScrollbar,
css` css`
@@ -658,80 +558,6 @@ export class HaPickerComboBox extends LitElement {
background-color: var(--ha-color-fill-neutral-normal-hover); background-color: var(--ha-color-fill-neutral-normal-hover);
} }
} }
.sections {
display: flex;
flex-wrap: nowrap;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-3);
overflow: auto;
}
:host([mode="dialog"]) .sections {
padding: var(--ha-space-3) var(--ha-space-4);
}
.sections ha-filter-chip {
flex-shrink: 0;
--md-filter-chip-selected-container-color: var(
--ha-color-fill-primary-normal-hover
);
color: var(--primary-color);
}
.sections .separator {
height: var(--ha-space-8);
width: 0;
border: 1px solid var(--ha-color-border-neutral-quiet);
}
.section-title,
.title {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
}
.title {
width: 100%;
}
:host([mode="dialog"]) .title {
padding: var(--ha-space-1) var(--ha-space-4);
}
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
.section-title-wrapper {
height: 0;
position: relative;
}
.section-title {
opacity: 0;
position: absolute;
top: 1px;
width: calc(100% - var(--ha-space-8));
}
.section-title.show {
opacity: 1;
z-index: 1;
}
.empty-search {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
padding: var(--ha-space-3);
}
`, `,
]; ];
} }

View File

@@ -1,8 +1,6 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import type { StateSelector } from "../../data/selector"; import type { StateSelector } from "../../data/selector";
import { extractFromTarget } from "../../data/target";
import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-state-picker"; import "../entity/ha-entity-state-picker";
@@ -27,29 +25,15 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public context?: { @property({ attribute: false }) public context?: {
filter_attribute?: string; filter_attribute?: string;
filter_entity?: string | string[]; filter_entity?: string | string[];
filter_target?: HassServiceTarget;
}; };
@state() private _entityIds?: string | string[];
willUpdate(changedProps) {
if (changedProps.has("selector") || changedProps.has("context")) {
this._resolveEntityIds(
this.selector.state?.entity_id,
this.context?.filter_entity,
this.context?.filter_target
).then((entityIds) => {
this._entityIds = entityIds;
});
}
}
protected render() { protected render() {
if (this.selector.state?.multiple) { if (this.selector.state?.multiple) {
return html` return html`
<ha-entity-states-picker <ha-entity-states-picker
.hass=${this.hass} .hass=${this.hass}
.entityId=${this._entityIds} .entityId=${this.selector.state?.entity_id ||
this.context?.filter_entity}
.attribute=${this.selector.state?.attribute || .attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute} this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options} .extraOptions=${this.selector.state?.extra_options}
@@ -66,7 +50,8 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
return html` return html`
<ha-entity-state-picker <ha-entity-state-picker
.hass=${this.hass} .hass=${this.hass}
.entityId=${this._entityIds} .entityId=${this.selector.state?.entity_id ||
this.context?.filter_entity}
.attribute=${this.selector.state?.attribute || .attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute} this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options} .extraOptions=${this.selector.state?.extra_options}
@@ -80,24 +65,6 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
></ha-entity-state-picker> ></ha-entity-state-picker>
`; `;
} }
private async _resolveEntityIds(
selectorEntityId: string | string[] | undefined,
contextFilterEntity: string | string[] | undefined,
contextFilterTarget: HassServiceTarget | undefined
): Promise<string | string[] | undefined> {
if (selectorEntityId !== undefined) {
return selectorEntityId;
}
if (contextFilterEntity !== undefined) {
return contextFilterEntity;
}
if (contextFilterTarget !== undefined) {
const result = await extractFromTarget(this.hass, contextFilterTarget);
return result.referenced_entities;
}
return undefined;
}
} }
declare global { declare global {

View File

@@ -33,7 +33,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
import "./ha-checkbox"; import "./ha-checkbox";
import "./ha-icon-button"; import "./ha-icon-button";
import "./ha-markdown";
import "./ha-selector/ha-selector"; import "./ha-selector/ha-selector";
import "./ha-service-picker"; import "./ha-service-picker";
import "./ha-service-section-icon"; import "./ha-service-section-icon";
@@ -685,14 +684,10 @@ export class HaServiceControl extends LitElement {
dataField.key}</span dataField.key}</span
> >
<span slot="description" <span slot="description"
><ha-markdown >${this.hass.localize(
breaks `component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
allow-svg ) || dataField?.description}</span
.content=${this.hass.localize( >
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}
></ha-markdown>
</span>
<ha-selector <ha-selector
.context=${this._selectorContext(targetEntities)} .context=${this._selectorContext(targetEntities)}
.disabled=${this.disabled || .disabled=${this.disabled ||

View File

@@ -33,7 +33,6 @@ import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle"; import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend"; import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler"; import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import { getDefaultPanelUrlPath } from "../data/panel";
import type { PersistentNotification } from "../data/persistent_notification"; import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification"; import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs"; import { subscribeRepairsIssueRegistry } from "../data/repairs";
@@ -143,7 +142,7 @@ const defaultPanelSorter = (
export const computePanels = memoizeOne( export const computePanels = memoizeOne(
( (
panels: HomeAssistant["panels"], panels: HomeAssistant["panels"],
defaultPanel: string, defaultPanel: HomeAssistant["defaultPanel"],
panelsOrder: string[], panelsOrder: string[],
hiddenPanels: string[], hiddenPanels: string[],
locale: HomeAssistant["locale"] locale: HomeAssistant["locale"]
@@ -158,9 +157,7 @@ export const computePanels = memoizeOne(
Object.values(panels).forEach((panel) => { Object.values(panels).forEach((panel) => {
if ( if (
hiddenPanels.includes(panel.url_path) || hiddenPanels.includes(panel.url_path) ||
(!panel.title && panel.url_path !== defaultPanel) || (!panel.title && panel.url_path !== defaultPanel)
(panel.default_visible === false &&
!panelsOrder.includes(panel.url_path))
) { ) {
return; return;
} }
@@ -299,8 +296,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
hass.localize !== oldHass.localize || hass.localize !== oldHass.localize ||
hass.locale !== oldHass.locale || hass.locale !== oldHass.locale ||
hass.states !== oldHass.states || hass.states !== oldHass.states ||
hass.userData !== oldHass.userData || hass.defaultPanel !== oldHass.defaultPanel ||
hass.systemData !== oldHass.systemData ||
hass.connected !== oldHass.connected hass.connected !== oldHass.connected
); );
} }
@@ -403,11 +399,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
`; `;
} }
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const [beforeSpacer, afterSpacer] = computePanels( const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels, this.hass.panels,
defaultPanel, this.hass.defaultPanel,
this._panelOrder, this._panelOrder,
this._hiddenPanels, this._hiddenPanels,
this.hass.locale this.hass.locale
@@ -422,27 +416,23 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll} @scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown} @keydown=${this._listboxKeydown}
> >
${this._renderPanels(beforeSpacer, selectedPanel, defaultPanel)} ${this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()} ${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel, defaultPanel)} ${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderExternalConfiguration()} ${this._renderExternalConfiguration()}
</ha-md-list> </ha-md-list>
`; `;
} }
private _renderPanels( private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
panels: PanelInfo[],
selectedPanel: string,
defaultPanel: string
) {
return panels.map((panel) => return panels.map((panel) =>
this._renderPanel( this._renderPanel(
panel.url_path, panel.url_path,
panel.url_path === defaultPanel panel.url_path === this.hass.defaultPanel
? panel.title || this.hass.localize("panel.states") ? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title, : this.hass.localize(`panel.${panel.title}`) || panel.title,
panel.icon, panel.icon,
panel.url_path === defaultPanel && !panel.icon panel.url_path === this.hass.defaultPanel && !panel.icon
? PANEL_ICONS.lovelace ? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS : panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path] ? PANEL_ICONS[panel.url_path]

View File

@@ -1,31 +1,15 @@
import "@home-assistant/webawesome/dist/components/popover/popover"; import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume } from "@lit/context";
// @ts-ignore // @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css"; import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { mdiPlus, mdiTextureBox } from "@mdi/js"; import { mdiPlaylistPlus } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing, unsafeCSS } from "lit"; import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array"; import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { isValidEntityId } from "../common/entity/valid_entity_id"; import { isValidEntityId } from "../common/entity/valid_entity_id";
import { computeRTL } from "../common/util/compute_rtl";
import {
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { labelsContext } from "../data/context";
import { getDevices, type DevicePickerItem } from "../data/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity"; import type { HaEntityPickerEntityFilterFunc } from "../data/entity";
import { getEntities, type EntityComboBoxItem } from "../data/entity_registry";
import { domainToName } from "../data/integration";
import { getLabels, type LabelRegistryEntry } from "../data/label_registry";
import { import {
areaMeetsFilter, areaMeetsFilter,
deviceMeetsFilter, deviceMeetsFilter,
@@ -34,23 +18,18 @@ import {
type TargetTypeFloorless, type TargetTypeFloorless,
} from "../data/target"; } from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail"; import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../resources/fuse";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker"; import "./ha-bottom-sheet";
import type { PickerComboBoxItem } from "./ha-picker-combo-box"; import "./ha-button";
import "./ha-input-helper-text";
import "./ha-svg-icon"; import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group"; import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-selector";
import type { HaTargetPickerSelector } from "./target-picker/ha-target-picker-selector";
import "./target-picker/ha-target-picker-value-chip"; import "./target-picker/ha-target-picker-value-chip";
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
@customElement("ha-target-picker") @customElement("ha-target-picker")
export class HaTargetPicker extends SubscribeMixin(LitElement) { export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -89,54 +68,23 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false; @property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
@state() private _selectedSection?: TargetTypeFloorless; @state() private _open = false;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {}; @state() private _addTargetWidth = 0;
@state() @state() private _narrow = false;
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[]; @state() private _pickerFilter?: TargetTypeFloorless;
@state() private _pickerWrapperOpen = false;
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLDivElement;
@query("ha-target-picker-selector")
private _targetPickerSelectorElement?: HaTargetPickerSelector;
private _newTarget?: { type: TargetType; id: string }; private _newTarget?: { type: TargetType; id: string };
private _getDevicesMemoized = memoizeOne(getDevices);
private _getLabelsMemoized = memoizeOne(getLabels);
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private get _showEntityId() {
return this.hass.userData?.showEntityIdPicker;
}
private _fuseIndexes = {
area: memoizeOne((states: FloorComboBoxItem[]) =>
this._createFuseIndex(states)
),
entity: memoizeOne((states: EntityComboBoxItem[]) =>
this._createFuseIndex(states)
),
device: memoizeOne((states: DevicePickerItem[]) =>
this._createFuseIndex(states)
),
label: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
),
};
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._loadConfigEntries();
}
}
private _createFuseIndex = (states) =>
Fuse.createIndex(["search_labels"], states);
protected render() { protected render() {
if (this.addOnTop) { if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `; return html` ${this._renderPicker()} ${this._renderItems()} `;
@@ -341,63 +289,137 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
} }
private _renderPicker() { private _renderPicker() {
const sections = [
{
id: "entity",
label: this.hass.localize("ui.components.target-picker.type.entities"),
},
{
id: "device",
label: this.hass.localize("ui.components.target-picker.type.devices"),
},
{
id: "area",
label: this.hass.localize("ui.components.target-picker.type.areas"),
},
"separator" as const,
{
id: "label",
label: this.hass.localize("ui.components.target-picker.type.labels"),
},
];
return html` return html`
<div class="add-target-wrapper"> <div class="add-target-wrapper">
<ha-generic-picker <ha-button
.hass=${this.hass} id="add-target-button"
.disabled=${this.disabled} size="small"
.autofocus=${this.autofocus} appearance="filled"
.helper=${this.helper} @click=${this._showPicker}
.sections=${sections}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.target-picker.no_targets"
)}
.sectionTitleFunction=${this._sectionTitleFunction}
.selectedSection=${this._selectedSection}
.rowRenderer=${this._renderRow}
.getItems=${this._getItems}
@value-changed=${this._targetPicked}
.addButtonLabel=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
.getAdditionalItems=${this._getAdditionalItems}
> >
</ha-generic-picker> <ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.components.target-picker.add_target")}
</ha-button>
${!this._narrow && (this._pickerWrapperOpen || this._open)
? html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._addTargetWidth}px;"
without-arrow
distance="-4"
placement="bottom-start"
for="add-target-button"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._showSelector}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
>
${this._renderTargetSelector()}
</wa-popover>
`
: this._pickerWrapperOpen || this._open
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._showSelector}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
>
${this._renderTargetSelector(true)}
</ha-bottom-sheet>`
: nothing}
</div> </div>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`; `;
} }
private _targetPicked(ev: CustomEvent<{ value: string }>) { connectedCallback() {
ev.stopPropagation(); super.connectedCallback();
const value = ev.detail.value; this._handleResize();
if (value.startsWith(CREATE_ID)) { window.addEventListener("resize", this._handleResize);
this._createNewDomainElement(value.substring(CREATE_ID.length)); }
return;
}
const [type, id] = ev.detail.value.split(SEPARATOR); public disconnectedCallback() {
this._addTarget(id, type as TargetType); super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
}
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
};
private _showPicker() {
this._addTargetWidth = this._addTargetWrapper?.offsetWidth || 0;
this._pickerWrapperOpen = true;
}
// wait for drawer animation to finish
private _showSelector = () => {
this._open = true;
requestAnimationFrame(() => {
this._targetPickerSelectorElement?.focus();
});
};
private _handleUpdatePickerFilter(
ev: CustomEvent<TargetTypeFloorless | undefined>
) {
this._updatePickerFilter(
typeof ev.detail === "string" ? ev.detail : undefined
);
}
private _updatePickerFilter = (filter?: TargetTypeFloorless) => {
this._pickerFilter = filter;
};
private _hidePicker(ev) {
ev.stopPropagation();
this._open = false;
this._pickerWrapperOpen = false;
if (this._newTarget) {
this._addTarget(this._newTarget.id, this._newTarget.type);
this._newTarget = undefined;
}
}
private _renderTargetSelector(dialogMode = false) {
if (!this._open) {
return nothing;
}
return html`
<ha-target-picker-selector
.hass=${this.hass}
@filter-type-changed=${this._handleUpdatePickerFilter}
.filterType=${this._pickerFilter}
@target-picked=${this._handleTargetPicked}
@create-domain-picked=${this._handleCreateDomain}
.targetValue=${this.value}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.createDomains=${this.createDomains}
.mode=${dialogMode ? "dialog" : "popover"}
></ha-target-picker-selector>
`;
} }
private _addTarget(id: string, type: TargetType) { private _addTarget(id: string, type: TargetType) {
@@ -432,7 +454,26 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
?.removeAttribute("collapsed"); ?.removeAttribute("collapsed");
} }
private _createNewDomainElement = (domain: string) => { private _handleTargetPicked = async (
ev: CustomEvent<{ type: TargetType; id: string }>
) => {
ev.stopPropagation();
this._pickerWrapperOpen = false;
if (!ev.detail.type || !ev.detail.id) {
return;
}
// save new target temporarily to add it after dialog closes
this._newTarget = ev.detail;
};
private _handleCreateDomain = (ev: CustomEvent<string>) => {
this._pickerWrapperOpen = false;
const domain = ev.detail;
showHelperDetailDialog(this, { showHelperDetailDialog(this, {
domain, domain,
dialogClosedCallback: (item) => { dialogClosedCallback: (item) => {
@@ -634,459 +675,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return undefined; return undefined;
} }
private _getRowType = (
item:
| PickerComboBoxItem
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem
) => {
if (
(item as FloorComboBoxItem).type === "area" ||
(item as FloorComboBoxItem).type === "floor"
) {
return (item as FloorComboBoxItem).type;
}
if ("domain" in item) {
return "device";
}
if ("stateObj" in item) {
return "entity";
}
if (item.id === EMPTY_SEARCH) {
return "empty";
}
return "label";
};
private _sectionTitleFunction = ({
firstIndex,
lastIndex,
firstItem,
secondItem,
itemsCount,
}: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => {
if (
firstItem === undefined ||
secondItem === undefined ||
typeof firstItem === "string" ||
(typeof secondItem === "string" && secondItem !== "padding") ||
(firstIndex === 0 && lastIndex === itemsCount - 1)
) {
return undefined;
}
const type = this._getRowType(firstItem as PickerComboBoxItem);
const translationType:
| "areas"
| "entities"
| "devices"
| "labels"
| undefined =
type === "area" || type === "floor"
? "areas"
: type === "entity"
? "entities"
: type && type !== "empty"
? `${type}s`
: undefined;
return translationType
? this.hass.localize(
`ui.components.target-picker.type.${translationType}`
)
: undefined;
};
private _getItems = (searchString: string, section: string) => {
this._selectedSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
this.hass.localize,
this.entityFilter,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.value,
searchString,
this._configEntryLookup,
this._selectedSection
);
};
private _getItemsMemoized = memoizeOne(
(
localize: HomeAssistant["localize"],
entityFilter: this["entityFilter"],
deviceFilter: this["deviceFilter"],
includeDomains: this["includeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
targetValue: this["value"],
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
filterType?: TargetTypeFloorless
) => {
const items: (
| string
| FloorComboBoxItem
| EntityComboBoxItem
| PickerComboBoxItem
)[] = [];
if (!filterType || filterType === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
includeDomains,
undefined,
entityFilter,
includeDeviceClasses,
undefined,
undefined,
targetValue?.entity_id
? ensureArray(targetValue.entity_id)
: undefined,
undefined,
`entity${SEPARATOR}`
);
if (searchTerm) {
entityItems = this._filterGroup(
"entity",
entityItems,
searchTerm,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
) as EntityComboBoxItem[];
}
if (!filterType && entityItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.entities"));
}
items.push(...entityItems);
}
if (!filterType || filterType === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.device_id
? ensureArray(targetValue.device_id)
: undefined,
undefined,
`device${SEPARATOR}`
);
if (searchTerm) {
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
}
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
}
items.push(...deviceItems);
}
if (!filterType || filterType === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
),
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined,
targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined
);
if (searchTerm) {
areasAndFloors = this._filterGroup(
"area",
areasAndFloors,
searchTerm
) as FloorComboBoxItem[];
}
if (!filterType && areasAndFloors.length) {
// show group title
items.push(localize("ui.components.target-picker.type.areas"));
}
items.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
}
if (!filterType || filterType === "label") {
let labels = this._getLabelsMemoized(
this.hass,
this._labelRegistry,
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined,
`label${SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup("label", labels, searchTerm);
}
if (!filterType && labels.length) {
// show group title
items.push(localize("ui.components.target-picker.type.labels"));
}
items.push(...labels);
}
return items;
}
);
private _filterGroup(
type: TargetType,
items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
searchTerm: string,
checkExact?: (
item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
) => boolean
) {
const fuseIndex = this._fuseIndexes[type](items);
const fuse = new HaFuse(
items,
{
shouldSort: false,
minMatchCharLength: Math.min(searchTerm.length, 2),
},
fuseIndex
);
const results = fuse.multiTermsSearch(searchTerm);
let filteredItems = items;
if (results) {
filteredItems = results.map((result) => result.item);
}
if (!checkExact) {
return filteredItems;
}
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex((item) => checkExact(item));
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
}
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
private _getCreateItems = memoizeOne(
(createDomains: this["createDomains"]) => {
if (!createDomains?.length) {
return [];
}
return createDomains.map((domain) => {
const primary = this.hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(this.hass.localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
}
);
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private _renderRow = (
item:
| PickerComboBoxItem
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem,
index: number
) => {
if (!item) {
return nothing;
}
const type = this._getRowType(item);
let hasFloor = false;
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
item.id = item[type]?.[`${type}_id`];
rtl = computeRTL(this.hass);
hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
}
if (type === "entity") {
showEntityId = !!this._showEntityId;
}
return html`
<ha-combo-box-item
id=${`list-item-${index}`}
tabindex="-1"
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${(item as FloorComboBoxItem).type === "area" && hasFloor
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""}
>
${(item as FloorComboBoxItem).type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "var(--ha-space-0)",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${(
item as FloorComboBoxItem & { last?: boolean | undefined }
).last}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: type === "entity" && (item as EntityComboBoxItem).stateObj
? html`
<state-badge
slot="start"
.stateObj=${(item as EntityComboBoxItem).stateObj}
.hass=${this.hass}
></state-badge>
`
: type === "device" && (item as DevicePickerItem).domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: (item as DevicePickerItem).domain!,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
})}
/>
`
: type === "floor"
? html`<ha-floor-icon
slot="start"
.floor=${(item as FloorComboBoxItem).floor!}
></ha-floor-icon>`
: type === "area"
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${(item as EntityComboBoxItem).stateObj && showEntityId
? html`
<span slot="supporting-text" class="code">
${(item as EntityComboBoxItem).stateObj?.entity_id}
</span>
`
: nothing}
${(item as EntityComboBoxItem).domain_name &&
(type !== "entity" || !showEntityId)
? html`
<div slot="trailing-supporting-text" class="domain">
${(item as EntityComboBoxItem).domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
};
private _noTargetFoundLabel = (search: string) =>
this.hass.localize("ui.components.target-picker.no_target_found", {
term: html`<b>${search}</b>`,
});
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
.add-target-wrapper { .add-target-wrapper {
@@ -1095,8 +683,31 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
margin-top: var(--ha-space-3); margin-top: var(--ha-space-3);
} }
ha-generic-picker { wa-popover {
width: 100%; --wa-space-l: var(--ha-space-0);
}
wa-popover::part(body) {
width: min(max(var(--body-width), 336px), 600px);
max-width: min(max(var(--body-width), 336px), 600px);
max-height: 500px;
height: 70vh;
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
--ha-bottom-sheet-max-width: 600px;
--ha-bottom-sheet-padding: var(--ha-space-0);
--ha-bottom-sheet-surface-background: var(--card-background-color);
} }
${unsafeCSS(chipStyles)} ${unsafeCSS(chipStyles)}

View File

@@ -1,97 +0,0 @@
import {
mdiAvTimer,
mdiCalendar,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiHomeAssistant,
mdiMapMarker,
mdiMapMarkerRadius,
mdiMessageAlert,
mdiMicrophoneMessage,
mdiNfcVariant,
mdiNumeric,
mdiStateMachine,
mdiSwapHorizontal,
mdiWeatherSunny,
mdiWebhook,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
export const TRIGGER_ICONS = {
calendar: mdiCalendar,
device: mdiDevices,
event: mdiGestureDoubleTap,
state: mdiStateMachine,
geo_location: mdiMapMarker,
homeassistant: mdiHomeAssistant,
mqtt: mdiSwapHorizontal,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
conversation: mdiMicrophoneMessage,
tag: mdiNfcVariant,
template: mdiCodeBraces,
time: mdiClockOutline,
time_pattern: mdiAvTimer,
webhook: mdiWebhook,
persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius,
list: mdiFormatListBulleted,
};
@customElement("ha-trigger-icon")
export class HaTriggerIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public trigger?: string;
@property() public icon?: string;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
}
if (!this.trigger) {
return nothing;
}
if (!this.hass) {
return this._renderFallback();
}
const icon = triggerIcon(this.hass, this.trigger).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
const domain = computeDomain(this.trigger!);
return html`
<ha-svg-icon
.path=${TRIGGER_ICONS[this.trigger!] || FALLBACK_DOMAIN_ICONS[domain]}
></ha-svg-icon>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-trigger-icon": HaTriggerIcon;
}
}

View File

@@ -1,4 +1,6 @@
import { css, html, LitElement } from "lit"; import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { import {
customElement, customElement,
eventOptions, eventOptions,
@@ -6,9 +8,6 @@ import {
query, query,
state, state,
} from "lit/decorators"; } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { mdiClose } from "@mdi/js";
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -32,8 +31,6 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* *
* @slot header - Replace the entire header area. * @slot header - Replace the entire header area.
* @slot headerNavigationIcon - Leading header action (e.g. close/back button). * @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerTitle - Custom title content (used when header-title is not set).
* @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set).
* @slot headerActionItems - Trailing header actions (e.g. buttons, menus). * @slot headerActionItems - Trailing header actions (e.g. buttons, menus).
* @slot - Dialog content body. * @slot - Dialog content body.
* @slot footer - Dialog footer content. * @slot footer - Dialog footer content.
@@ -55,8 +52,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @attr {boolean} open - Controls the dialog open state. * @attr {boolean} open - Controls the dialog open state.
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium". * @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium".
* @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false. * @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false.
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used. * @attr {string} header-title - Header title text when no custom title slot is provided.
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used. * @attr {string} header-subtitle - Header subtitle text when no custom subtitle slot is provided.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below". * @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts. * @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts.
* *
@@ -75,12 +72,6 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
export class HaWaDialog extends LitElement { export class HaWaDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@property({ attribute: "aria-describedby" })
public ariaDescribedBy?: string;
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
public open = false; public open = false;
@@ -90,11 +81,11 @@ export class HaWaDialog extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" }) @property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false; public preventScrimClose = false;
@property({ attribute: "header-title" }) @property({ type: String, attribute: "header-title" })
public headerTitle?: string; public headerTitle = "";
@property({ attribute: "header-subtitle" }) @property({ type: String, attribute: "header-subtitle" })
public headerSubtitle?: string; public headerSubtitle = "";
@property({ type: String, attribute: "header-subtitle-position" }) @property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below"; public headerSubtitlePosition: "above" | "below" = "below";
@@ -126,11 +117,6 @@ export class HaWaDialog extends LitElement {
.open=${this._open} .open=${this._open}
.lightDismiss=${!this.preventScrimClose} .lightDismiss=${!this.preventScrimClose}
without-header without-header
aria-labelledby=${ifDefined(
this.ariaLabelledBy ||
(this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined)
)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@wa-show=${this._handleShow} @wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow} @wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide} @wa-after-hide=${this._handleAfterHide}
@@ -147,14 +133,14 @@ export class HaWaDialog extends LitElement {
.path=${mdiClose} .path=${mdiClose}
></ha-icon-button> ></ha-icon-button>
</slot> </slot>
${this.headerTitle !== undefined ${this.headerTitle
? html`<span slot="title" class="title" id="ha-wa-dialog-title"> ? html`<span slot="title" class="title">
${this.headerTitle} ${this.headerTitle}
</span>` </span>`
: html`<slot name="headerTitle" slot="title"></slot>`} : nothing}
${this.headerSubtitle !== undefined ${this.headerSubtitle
? html`<span slot="subtitle">${this.headerSubtitle}</span>` ? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`} : nothing}
<slot name="headerActionItems" slot="actionItems"></slot> <slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header> </ha-dialog-header>
</slot> </slot>

View File

@@ -62,10 +62,6 @@ class HaWebRtcPlayer extends LitElement {
private _candidatesList: RTCIceCandidate[] = []; private _candidatesList: RTCIceCandidate[] = [];
private _handleVisibilityChange = () => { private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) { if (document.hidden) {
this._cleanUp(); this._cleanUp();
} else { } else {

View File

@@ -18,7 +18,7 @@ import {
removeLocalMedia, removeLocalMedia,
} from "../../data/media_source"; } from "../../data/media_source";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-button"; import "../ha-button";
import "../ha-check-list-item"; import "../ha-check-list-item";
@@ -305,7 +305,6 @@ class DialogMediaManage extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
--dialog-z-index: 9; --dialog-z-index: 9;
@@ -315,9 +314,9 @@ class DialogMediaManage extends LitElement {
@media (min-width: 800px) { @media (min-width: 800px) {
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 800px; --mdc-dialog-max-width: 800px;
--mdc-dialog-max-height: calc( --dialog-surface-position: fixed;
100vh - var(--ha-space-18) - var(--safe-area-inset-y) --dialog-surface-top: 40px;
); --mdc-dialog-max-height: calc(100vh - 72px);
} }
} }

View File

@@ -19,7 +19,7 @@ import type {
MediaPlayerItem, MediaPlayerItem,
MediaPlayerLayoutType, MediaPlayerLayoutType,
} from "../../data/media-player"; } from "../../data/media-player";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-dialog"; import "../ha-dialog";
import "../ha-dialog-header"; import "../ha-dialog-header";
@@ -223,7 +223,6 @@ class DialogMediaPlayerBrowse extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
--dialog-z-index: 9; --dialog-z-index: 9;
@@ -231,27 +230,23 @@ class DialogMediaPlayerBrowse extends LitElement {
} }
ha-media-player-browse { ha-media-player-browse {
--media-browser-max-height: calc( --media-browser-max-height: calc(100vh - 65px);
100vh - 65px - var(--safe-area-inset-y)
);
} }
:host(.opened) ha-media-player-browse { :host(.opened) ha-media-player-browse {
height: calc(100vh - 65px - var(--safe-area-inset-y)); height: calc(100vh - 65px);
} }
@media (min-width: 800px) { @media (min-width: 800px) {
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 800px; --mdc-dialog-max-width: 800px;
--mdc-dialog-max-height: calc( --dialog-surface-position: fixed;
100vh - var(--ha-space-18) - var(--safe-area-inset-y) --dialog-surface-top: 40px;
); --mdc-dialog-max-height: calc(100vh - 72px);
} }
ha-media-player-browse { ha-media-player-browse {
position: initial; position: initial;
--media-browser-max-height: calc( --media-browser-max-height: calc(100vh - 145px);
100vh - 145px - var(--safe-area-inset-y)
);
width: 700px; width: 700px;
} }
} }

View File

@@ -34,7 +34,6 @@ class SearchInput extends LitElement {
return html` return html`
<ha-textfield <ha-textfield
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
autocomplete="off"
.label=${this.label || this.hass.localize("ui.common.search")} .label=${this.label || this.hass.localize("ui.common.search")}
.value=${this.filter || ""} .value=${this.filter || ""}
icon icon

File diff suppressed because it is too large Load Diff

View File

@@ -128,7 +128,9 @@ class HaUserPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this.hass.localize(
"ui.components.user-picker.no_match"
)}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
@@ -147,11 +149,6 @@ class HaUserPicker extends LitElement {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.user-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -50,7 +50,7 @@ export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{ {
groups: { groups: {
device_id: {}, device_id: {},
dynamicGroups: {}, serviceGroups: {},
}, },
}, },
{ {
@@ -117,6 +117,14 @@ export const VIRTUAL_ACTIONS: Partial<
}, },
} as const; } as const;
export const SERVICE_PREFIX = "__SERVICE__";
export const isService = (key: string | undefined): boolean | undefined =>
key?.startsWith(SERVICE_PREFIX);
export const getService = (key: string): string =>
key.substring(SERVICE_PREFIX.length);
export const COLLAPSIBLE_ACTION_ELEMENTS = [ export const COLLAPSIBLE_ACTION_ELEMENTS = [
"ha-automation-action-choose", "ha-automation-action-choose",
"ha-automation-action-condition", "ha-automation-action-condition",

View File

@@ -1,4 +1,3 @@
import { getAreasFloorHierarchy } from "../common/areas/areas-floor-hierarchy";
import { computeAreaName } from "../common/entity/compute_area_name"; import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name"; import { computeFloorName } from "../common/entity/compute_floor_name";
@@ -13,7 +12,11 @@ import {
} from "./device_registry"; } from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity"; import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry"; import type { EntityRegistryDisplayEntry } from "./entity_registry";
import type { FloorRegistryEntry } from "./floor_registry"; import {
floorCompare,
getFloorAreaLookup,
type FloorRegistryEntry,
} from "./floor_registry";
export interface FloorComboBoxItem extends PickerComboBoxItem { export interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area"; type: "floor" | "area";
@@ -179,59 +182,68 @@ export const getAreasAndFloors = (
); );
} }
const hierarchy = getAreasFloorHierarchy(floors, outputAreas); const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassignedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
const compare = floorCompare(haFloors);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id));
const items: FloorComboBoxItem[] = []; const items: FloorComboBoxItem[] = [];
hierarchy.floors.forEach((f) => { floorAreaEntries.forEach(([floor, floorAreas]) => {
const floor = haFloors[f.id]; if (floor) {
const floorAreas = f.areas.map((areaId) => haAreas[areaId]); const floorName = computeFloorName(floor);
const floorName = computeFloorName(floor); const areaSearchLabels = floorAreas
.map((area) => {
const areaSearchLabels = floorAreas const areaName = computeAreaName(area) || area.area_id;
.map((area) => { return [area.area_id, areaName, ...area.aliases];
const areaName = computeAreaName(area); })
return [area.area_id, ...(areaName ? [areaName] : []), ...area.aliases]; .flat();
})
.flat();
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
items.push( items.push(
...floorAreas.map((area) => { ...floorAreas.map((area) => {
const areaName = computeAreaName(area); const areaName = computeAreaName(area) || area.area_id;
return { return {
id: formatId({ id: area.area_id, type: "area" }), id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const, type: "area" as const,
primary: areaName || area.area_id, primary: areaName,
area: area, area: area,
icon: area.icon || undefined, icon: area.icon || undefined,
search_labels: [ search_labels: [area.area_id, areaName, ...area.aliases],
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
}; };
}) })
); );
}); });
items.push( items.push(
...hierarchy.areas.map((areaId) => { ...unassignedAreas.map((area) => {
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id; const areaName = computeAreaName(area) || area.area_id;
return { return {
id: formatId({ id: area.area_id, type: "area" }), id: formatId({ id: area.area_id, type: "area" }),

View File

@@ -59,15 +59,6 @@ export const deleteAreaRegistryEntry = (hass: HomeAssistant, areaId: string) =>
area_id: areaId, area_id: areaId,
}); });
export const reorderAreaRegistryEntries = (
hass: HomeAssistant,
areaIds: string[]
) =>
hass.callWS({
type: "config/area_registry/reorder",
area_ids: areaIds,
});
export const getAreaEntityLookup = ( export const getAreaEntityLookup = (
entities: EntityRegistryEntry[] entities: EntityRegistryEntry[]
): AreaEntityLookup => { ): AreaEntityLookup => {

View File

@@ -1,10 +1,8 @@
import type { import type {
HassEntityAttributeBase, HassEntityAttributeBase,
HassEntityBase, HassEntityBase,
HassServiceTarget,
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array"; import { ensureArray } from "../common/array/ensure-array";
import type { WeekdayShort } from "../common/datetime/weekday";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
import type { LocalizeKeys } from "../common/translations/localize"; import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params"; import { createSearchParam } from "../common/url/search-params";
@@ -14,19 +12,10 @@ import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation"; import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script"; import type { Action, Field, MODES } from "./script";
import { migrateAutomationAction } from "./script"; import { migrateAutomationAction } from "./script";
import type { TriggerDescription } from "./trigger";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single"; export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10; export const AUTOMATION_DEFAULT_MAX = 10;
export const DYNAMIC_PREFIX = "__DYNAMIC__";
export const isDynamic = (key: string | undefined): boolean | undefined =>
key?.startsWith(DYNAMIC_PREFIX);
export const getValueFromDynamic = (key: string): string =>
key.substring(DYNAMIC_PREFIX.length);
export interface AutomationEntity extends HassEntityBase { export interface AutomationEntity extends HassEntityBase {
attributes: HassEntityAttributeBase & { attributes: HassEntityAttributeBase & {
id?: string; id?: string;
@@ -96,12 +85,6 @@ export interface BaseTrigger {
id?: string; id?: string;
variables?: Record<string, unknown>; variables?: Record<string, unknown>;
enabled?: boolean; enabled?: boolean;
options?: Record<string, unknown>;
}
export interface PlatformTrigger extends BaseTrigger {
trigger: Exclude<string, LegacyTrigger["trigger"]>;
target?: HassServiceTarget;
} }
export interface StateTrigger extends BaseTrigger { export interface StateTrigger extends BaseTrigger {
@@ -211,7 +194,7 @@ export interface CalendarTrigger extends BaseTrigger {
offset: string; offset: string;
} }
export type LegacyTrigger = export type Trigger =
| StateTrigger | StateTrigger
| MqttTrigger | MqttTrigger
| GeoLocationTrigger | GeoLocationTrigger
@@ -228,9 +211,8 @@ export type LegacyTrigger =
| TemplateTrigger | TemplateTrigger
| EventTrigger | EventTrigger
| DeviceTrigger | DeviceTrigger
| CalendarTrigger; | CalendarTrigger
| TriggerList;
export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
interface BaseCondition { interface BaseCondition {
condition: string; condition: string;
@@ -275,11 +257,13 @@ export interface ZoneCondition extends BaseCondition {
zone: string; zone: string;
} }
type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
export interface TimeCondition extends BaseCondition { export interface TimeCondition extends BaseCondition {
condition: "time"; condition: "time";
after?: string; after?: string;
before?: string; before?: string;
weekday?: WeekdayShort | WeekdayShort[]; weekday?: Weekday | Weekday[];
} }
export interface TemplateCondition extends BaseCondition { export interface TemplateCondition extends BaseCondition {
@@ -592,7 +576,6 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig {
insertAfter: (value: Trigger | Trigger[]) => boolean; insertAfter: (value: Trigger | Trigger[]) => boolean;
toggleYamlMode: () => void; toggleYamlMode: () => void;
config: Trigger; config: Trigger;
description?: TriggerDescription;
yamlMode: boolean; yamlMode: boolean;
uiSupported: boolean; uiSupported: boolean;
} }

View File

@@ -16,9 +16,8 @@ import {
formatListWithAnds, formatListWithAnds,
formatListWithOrs, formatListWithOrs,
} from "../common/string/format-list"; } from "../common/string/format-list";
import { hasTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation"; import type { Condition, ForDict, Trigger } from "./automation";
import type { DeviceCondition, DeviceTrigger } from "./device_automation"; import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import { import {
localizeDeviceAutomationCondition, localizeDeviceAutomationCondition,
@@ -26,7 +25,8 @@ import {
} from "./device_automation"; } from "./device_automation";
import type { EntityRegistryEntry } from "./entity_registry"; import type { EntityRegistryEntry } from "./entity_registry";
import type { FrontendLocaleData } from "./translation"; import type { FrontendLocaleData } from "./translation";
import { getTriggerDomain, getTriggerObjectId, isTriggerList } from "./trigger"; import { isTriggerList } from "./trigger";
import { hasTemplate } from "../common/string/has-template";
const triggerTranslationBaseKey = const triggerTranslationBaseKey =
"ui.panel.config.automation.editor.triggers.type"; "ui.panel.config.automation.editor.triggers.type";
@@ -121,37 +121,6 @@ const tryDescribeTrigger = (
return trigger.alias; return trigger.alias;
} }
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry
);
if (description) {
return description;
}
const triggerType = trigger.trigger;
const domain = getTriggerDomain(trigger.trigger);
const type = getTriggerObjectId(trigger.trigger);
return (
hass.localize(
`component.${domain}.triggers.${type}.description_configured`
) ||
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${triggerType as LegacyTrigger["trigger"]}.label`
) ||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
);
};
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
) => {
// Event Trigger // Event Trigger
if (trigger.trigger === "event" && trigger.event_type) { if (trigger.trigger === "event" && trigger.event_type) {
const eventTypes: string[] = []; const eventTypes: string[] = [];
@@ -833,7 +802,13 @@ const describeLegacyTrigger = (
} }
); );
} }
return undefined;
return (
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
) ||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
);
}; };
export const describeCondition = ( export const describeCondition = (

View File

@@ -10,7 +10,6 @@ import {
import { formatTime } from "../common/datetime/format_time"; import { formatTime } from "../common/datetime/format_time";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { fileDownload } from "../util/file_download"; import { fileDownload } from "../util/file_download";
import { handleFetchPromise } from "../util/hass-call-api"; import { handleFetchPromise } from "../util/hass-call-api";
import type { BackupManagerState, ManagerStateEvent } from "./backup_manager"; import type { BackupManagerState, ManagerStateEvent } from "./backup_manager";
@@ -415,7 +414,7 @@ ${hass.auth.data.hassUrl}
${hass.localize("ui.panel.config.backup.emergency_kit_file.encryption_key")} ${hass.localize("ui.panel.config.backup.emergency_kit_file.encryption_key")}
${encryptionKey} ${encryptionKey}
${hass.localize("ui.panel.config.backup.emergency_kit_file.more_info", { link: documentationUrl(hass, "/more-info/backup-emergency-kit") })}`); ${hass.localize("ui.panel.config.backup.emergency_kit_file.more_info", { link: "https://www.home-assistant.io/more-info/backup-emergency-kit" })}`);
export const geneateEmergencyKitFileName = ( export const geneateEmergencyKitFileName = (
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -31,7 +31,6 @@ export interface CalendarEventData {
dtend: string; dtend: string;
rrule?: string; rrule?: string;
description?: string; description?: string;
location?: string;
} }
export interface CalendarEventMutableParams { export interface CalendarEventMutableParams {
@@ -40,7 +39,6 @@ export interface CalendarEventMutableParams {
dtend: string; dtend: string;
rrule?: string; rrule?: string;
description?: string; description?: string;
location?: string;
} }
// The scope of a delete/update for a recurring event // The scope of a delete/update for a recurring event
@@ -98,7 +96,6 @@ export const fetchCalendarEvents = async (
uid: ev.uid, uid: ev.uid,
summary: ev.summary, summary: ev.summary,
description: ev.description, description: ev.description,
location: ev.location,
dtstart: eventStart, dtstart: eventStart,
dtend: eventEnd, dtend: eventEnd,
recurrence_id: ev.recurrence_id, recurrence_id: ev.recurrence_id,

View File

@@ -186,8 +186,7 @@ export const getDevices = (
deviceFilter?: HaDevicePickerDeviceFilterFunc, deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc, entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[], excludeDevices?: string[],
value?: string, value?: string
idPrefix = ""
): DevicePickerItem[] => { ): DevicePickerItem[] => {
const devices = Object.values(hass.devices); const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities); const entities = Object.values(hass.entities);
@@ -299,7 +298,7 @@ export const getDevices = (
const domainName = domain ? domainToName(hass.localize, domain) : undefined; const domainName = domain ? domainToName(hass.localize, domain) : undefined;
return { return {
id: `${idPrefix}${device.id}`, id: device.id,
label: "", label: "",
primary: primary:
deviceName || deviceName ||

View File

@@ -102,7 +102,6 @@ export type EnergySolarForecasts = Record<string, EnergySolarForecast>;
export interface DeviceConsumptionEnergyPreference { export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value // This is an ever increasing value
stat_consumption: string; stat_consumption: string;
stat_rate?: string;
name?: string; name?: string;
included_in_stat?: string; included_in_stat?: string;
} }
@@ -131,17 +130,11 @@ export interface FlowToGridSourceEnergyPreference {
number_energy_price: number | null; number_energy_price: number | null;
} }
export interface GridPowerSourceEnergyPreference {
// W meter
stat_rate: string;
}
export interface GridSourceTypeEnergyPreference { export interface GridSourceTypeEnergyPreference {
type: "grid"; type: "grid";
flow_from: FlowFromGridSourceEnergyPreference[]; flow_from: FlowFromGridSourceEnergyPreference[];
flow_to: FlowToGridSourceEnergyPreference[]; flow_to: FlowToGridSourceEnergyPreference[];
power?: GridPowerSourceEnergyPreference[];
cost_adjustment_day: number; cost_adjustment_day: number;
} }
@@ -150,7 +143,6 @@ export interface SolarSourceTypeEnergyPreference {
type: "solar"; type: "solar";
stat_energy_from: string; stat_energy_from: string;
stat_rate?: string;
config_entry_solar_forecast: string[] | null; config_entry_solar_forecast: string[] | null;
} }
@@ -158,7 +150,6 @@ export interface BatterySourceTypeEnergyPreference {
type: "battery"; type: "battery";
stat_energy_from: string; stat_energy_from: string;
stat_energy_to: string; stat_energy_to: string;
stat_rate?: string;
} }
export interface GasSourceTypeEnergyPreference { export interface GasSourceTypeEnergyPreference {
type: "gas"; type: "gas";
@@ -360,35 +351,6 @@ export const getReferencedStatisticIds = (
return statIDs; return statIDs;
}; };
export const getReferencedStatisticIdsPower = (
prefs: EnergyPreferences
): string[] => {
const statIDs: (string | undefined)[] = [];
for (const source of prefs.energy_sources) {
if (source.type === "gas" || source.type === "water") {
continue;
}
if (source.type === "solar") {
statIDs.push(source.stat_rate);
continue;
}
if (source.type === "battery") {
statIDs.push(source.stat_rate);
continue;
}
if (source.power) {
statIDs.push(...source.power.map((p) => p.stat_rate));
}
}
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
return statIDs.filter(Boolean) as string[];
};
export const enum CompareMode { export const enum CompareMode {
NONE = "", NONE = "",
PREVIOUS = "previous", PREVIOUS = "previous",
@@ -436,10 +398,9 @@ const getEnergyData = async (
"gas", "gas",
"device", "device",
]); ]);
const powerStatIds = getReferencedStatisticIdsPower(prefs);
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]); const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds]; const allStatIDs = [...energyStatIds, ...waterStatIds];
const dayDifference = differenceInDays(end || new Date(), start); const dayDifference = differenceInDays(end || new Date(), start);
const period = const period =
@@ -450,8 +411,6 @@ const getEnergyData = async (
: dayDifference > 2 : dayDifference > 2
? "day" ? "day"
: "hour"; : "hour";
const finePeriod =
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
const statsMetadata: Record<string, StatisticsMetaData> = {}; const statsMetadata: Record<string, StatisticsMetaData> = {};
const statsMetadataArray = allStatIDs.length const statsMetadataArray = allStatIDs.length
@@ -473,9 +432,6 @@ const getEnergyData = async (
? (gasUnit as (typeof VOLUME_UNITS)[number]) ? (gasUnit as (typeof VOLUME_UNITS)[number])
: undefined, : undefined,
}; };
const powerUnits: StatisticsUnitConfiguration = {
power: "kW",
};
const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata); const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata);
const waterUnits: StatisticsUnitConfiguration = { const waterUnits: StatisticsUnitConfiguration = {
volume: waterUnit, volume: waterUnit,
@@ -486,12 +442,6 @@ const getEnergyData = async (
"change", "change",
]) ])
: {}; : {};
const _powerStats: Statistics | Promise<Statistics> = powerStatIds.length
? fetchStatistics(hass!, start, end, powerStatIds, finePeriod, powerUnits, [
"mean",
])
: {};
const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length
? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [ ? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [
"change", "change",
@@ -598,7 +548,6 @@ const getEnergyData = async (
const [ const [
energyStats, energyStats,
powerStats,
waterStats, waterStats,
energyStatsCompare, energyStatsCompare,
waterStatsCompare, waterStatsCompare,
@@ -606,14 +555,13 @@ const getEnergyData = async (
fossilEnergyConsumptionCompare, fossilEnergyConsumptionCompare,
] = await Promise.all([ ] = await Promise.all([
_energyStats, _energyStats,
_powerStats,
_waterStats, _waterStats,
_energyStatsCompare, _energyStatsCompare,
_waterStatsCompare, _waterStatsCompare,
_fossilEnergyConsumption, _fossilEnergyConsumption,
_fossilEnergyConsumptionCompare, _fossilEnergyConsumptionCompare,
]); ]);
const stats = { ...energyStats, ...waterStats, ...powerStats }; const stats = { ...energyStats, ...waterStats };
if (compare) { if (compare) {
statsCompare = { ...energyStatsCompare, ...waterStatsCompare }; statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
} }

View File

@@ -344,8 +344,7 @@ export const getEntities = (
includeUnitOfMeasurement?: string[], includeUnitOfMeasurement?: string[],
includeEntities?: string[], includeEntities?: string[],
excludeEntities?: string[], excludeEntities?: string[],
value?: string, value?: string
idPrefix = ""
): EntityComboBoxItem[] => { ): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = []; let items: EntityComboBoxItem[] = [];
@@ -396,9 +395,10 @@ export const getEntities = (
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) .filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ "); .join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return { return {
id: `${idPrefix}${entityId}`, id: entityId,
primary: primary, primary: primary,
secondary: secondary, secondary: secondary,
domain_name: domainName, domain_name: domainName,
@@ -411,6 +411,7 @@ export const getEntities = (
friendlyName, friendlyName,
entityId, entityId,
].filter(Boolean) as string[], ].filter(Boolean) as string[],
a11y_label: a11yLabel,
stateObj: stateObj, stateObj: stateObj,
}; };
}); });

View File

@@ -51,15 +51,6 @@ export const deleteFloorRegistryEntry = (
floor_id: floorId, floor_id: floorId,
}); });
export const reorderFloorRegistryEntries = (
hass: HomeAssistant,
floorIds: string[]
) =>
hass.callWS({
type: "config/floor_registry/reorder",
floor_ids: floorIds,
});
export const getFloorAreaLookup = ( export const getFloorAreaLookup = (
areas: AreaRegistryEntry[] areas: AreaRegistryEntry[]
): FloorAreaLookup => { ): FloorAreaLookup => {

View File

@@ -3,7 +3,6 @@ import type { Connection } from "home-assistant-js-websocket";
export interface CoreFrontendUserData { export interface CoreFrontendUserData {
showAdvanced?: boolean; showAdvanced?: boolean;
showEntityIdPicker?: boolean; showEntityIdPicker?: boolean;
default_panel?: string;
} }
export interface SidebarFrontendUserData { export interface SidebarFrontendUserData {
@@ -11,29 +10,15 @@ export interface SidebarFrontendUserData {
hiddenPanels: string[]; hiddenPanels: string[];
} }
export interface CoreFrontendSystemData {
default_panel?: string;
}
export interface HomeFrontendSystemData {
favorite_entities?: string[];
}
declare global { declare global {
interface FrontendUserData { interface FrontendUserData {
core: CoreFrontendUserData; core: CoreFrontendUserData;
sidebar: SidebarFrontendUserData; sidebar: SidebarFrontendUserData;
} }
interface FrontendSystemData {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
}
} }
export type ValidUserDataKey = keyof FrontendUserData; export type ValidUserDataKey = keyof FrontendUserData;
export type ValidSystemDataKey = keyof FrontendSystemData;
export const fetchFrontendUserData = async < export const fetchFrontendUserData = async <
UserDataKey extends ValidUserDataKey, UserDataKey extends ValidUserDataKey,
>( >(
@@ -74,46 +59,3 @@ export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
key: userDataKey, key: userDataKey,
} }
); );
export const fetchFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey
): Promise<FrontendSystemData[SystemDataKey] | null> => {
const result = await conn.sendMessagePromise<{
value: FrontendSystemData[SystemDataKey] | null;
}>({
type: "frontend/get_system_data",
key,
});
return result.value;
};
export const saveFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey,
value: FrontendSystemData[SystemDataKey]
): Promise<void> =>
conn.sendMessagePromise<undefined>({
type: "frontend/set_system_data",
key,
value,
});
export const subscribeFrontendSystemData = <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
systemDataKey: SystemDataKey,
onChange: (data: { value: FrontendSystemData[SystemDataKey] | null }) => void
) =>
conn.subscribeMessage<{ value: FrontendSystemData[SystemDataKey] | null }>(
onChange,
{
type: "frontend/subscribe_system_data",
key: systemDataKey,
}
);

View File

@@ -59,7 +59,6 @@ import type {
} from "./entity_registry"; } from "./entity_registry";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { getTriggerDomain, getTriggerObjectId } from "./trigger";
/** Icon to use when no icon specified for service. */ /** Icon to use when no icon specified for service. */
export const DEFAULT_SERVICE_ICON = mdiRoomService; export const DEFAULT_SERVICE_ICON = mdiRoomService;
@@ -134,19 +133,14 @@ const resources: {
all?: Promise<Record<string, ServiceIcons>>; all?: Promise<Record<string, ServiceIcons>>;
domains: Record<string, ServiceIcons | Promise<ServiceIcons>>; domains: Record<string, ServiceIcons | Promise<ServiceIcons>>;
}; };
triggers: {
all?: Promise<Record<string, TriggerIcons>>;
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
};
} = { } = {
entity: {}, entity: {},
entity_component: {}, entity_component: {},
services: { domains: {} }, services: { domains: {} },
triggers: { domains: {} },
}; };
interface IconResources< interface IconResources<
T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons, T extends ComponentIcons | PlatformIcons | ServiceIcons,
> { > {
resources: Record<string, T>; resources: Record<string, T>;
} }
@@ -190,22 +184,12 @@ type ServiceIcons = Record<
{ service: string; sections?: Record<string, string> } { service: string; sections?: Record<string, string> }
>; >;
type TriggerIcons = Record< export type IconCategory = "entity" | "entity_component" | "services";
string,
{ trigger: string; sections?: Record<string, string> }
>;
export type IconCategory =
| "entity"
| "entity_component"
| "services"
| "triggers";
interface CategoryType { interface CategoryType {
entity: PlatformIcons; entity: PlatformIcons;
entity_component: ComponentIcons; entity_component: ComponentIcons;
services: ServiceIcons; services: ServiceIcons;
triggers: TriggerIcons;
} }
export const getHassIcons = async <T extends IconCategory>( export const getHassIcons = async <T extends IconCategory>(
@@ -274,59 +258,42 @@ export const getComponentIcons = async (
return resources.entity_component.resources.then((res) => res[domain]); return resources.entity_component.resources.then((res) => res[domain]);
}; };
export const getCategoryIcons = async < export const getServiceIcons = async (
T extends Exclude<IconCategory, "entity" | "entity_component">,
>(
hass: HomeAssistant, hass: HomeAssistant,
category: T,
domain?: string, domain?: string,
force = false force = false
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => { ): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> => {
if (!domain) { if (!domain) {
if (!force && resources[category].all) { if (!force && resources.services.all) {
return resources[category].all as Promise< return resources.services.all;
Record<string, CategoryType[T]>
>;
} }
resources[category].all = getHassIcons(hass, category).then((res) => { resources.services.all = getHassIcons(hass, "services", domain).then(
resources[category].domains = res.resources as any; (res) => {
return res?.resources as Record<string, CategoryType[T]>; resources.services.domains = res.resources;
}) as any; return res?.resources;
return resources[category].all as Promise<Record<string, CategoryType[T]>>; }
);
return resources.services.all;
} }
if (!force && domain in resources[category].domains) { if (!force && domain in resources.services.domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>; return resources.services.domains[domain];
} }
if (resources[category].all && !force) { if (resources.services.all && !force) {
await resources[category].all; await resources.services.all;
if (domain in resources[category].domains) { if (domain in resources.services.domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>; return resources.services.domains[domain];
} }
} }
if (!isComponentLoaded(hass, domain)) { if (!isComponentLoaded(hass, domain)) {
return undefined; return undefined;
} }
const result = getHassIcons(hass, category, domain); const result = getHassIcons(hass, "services", domain);
resources[category].domains[domain] = result.then( resources.services.domains[domain] = result.then(
(res) => res?.resources[domain] (res) => res?.resources[domain]
) as any; );
return resources[category].domains[domain] as Promise<CategoryType[T]>; return resources.services.domains[domain];
}; };
export const getServiceIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> =>
getCategoryIcons(hass, "services", domain, force);
export const getTriggerIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
getCategoryIcons(hass, "triggers", domain, force);
// Cache for sorted range keys // Cache for sorted range keys
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>(); const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
@@ -506,26 +473,6 @@ export const attributeIcon = async (
return icon; return icon;
}; };
export const triggerIcon = async (
hass: HomeAssistant,
trigger: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = getTriggerDomain(trigger);
const triggerName = getTriggerObjectId(trigger);
const triggerIcons = await getTriggerIcons(hass, domain);
if (triggerIcons) {
const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string];
icon = trgrIcon?.trigger;
}
if (!icon) {
icon = await domainIcon(hass, domain);
}
return icon;
};
export const serviceIcon = async ( export const serviceIcon = async (
hass: HomeAssistant, hass: HomeAssistant,
service: string service: string

View File

@@ -108,8 +108,7 @@ export const getLabels = (
includeDeviceClasses?: string[], includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc, deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc, entityFilter?: HaEntityPickerEntityFilterFunc,
excludeLabels?: string[], excludeLabels?: string[]
idPrefix = ""
): PickerComboBoxItem[] => { ): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) { if (!labels || labels.length === 0) {
return []; return [];
@@ -263,7 +262,7 @@ export const getLabels = (
} }
const items = outputLabels.map<PickerComboBoxItem>((label) => ({ const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: `${idPrefix}${label.label_id}`, id: label.label_id,
primary: label.name, primary: label.name,
secondary: label.description ?? "", secondary: label.description ?? "",
icon: label.icon || undefined, icon: label.icon || undefined,

View File

@@ -1,78 +0,0 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
export interface LabPreviewFeature {
preview_feature: string;
domain: string;
enabled: boolean;
is_built_in: boolean;
feedback_url?: string;
learn_more_url?: string;
report_issue_url?: string;
}
export interface LabPreviewFeaturesResponse {
features: LabPreviewFeature[];
}
export const fetchLabFeatures = async (
hass: HomeAssistant
): Promise<LabPreviewFeature[]> => {
const response = await hass.callWS<LabPreviewFeaturesResponse>({
type: "labs/list",
});
return response.features;
};
export const labsUpdatePreviewFeature = (
hass: HomeAssistant,
domain: string,
preview_feature: string,
enabled: boolean,
create_backup?: boolean
): Promise<void> =>
hass.callWS({
type: "labs/update",
domain,
preview_feature,
enabled,
...(create_backup !== undefined && { create_backup }),
});
const fetchLabFeaturesCollection = (conn: Connection) =>
conn
.sendMessagePromise<LabPreviewFeaturesResponse>({
type: "labs/list",
})
.then((response) => response.features);
const subscribeLabUpdates = (
conn: Connection,
store: Store<LabPreviewFeature[]>
) =>
conn.subscribeEvents(
debounce(
() =>
fetchLabFeaturesCollection(conn).then((features: LabPreviewFeature[]) =>
store.setState(features, true)
),
500,
true
),
"labs_updated"
);
export const subscribeLabFeatures = (
conn: Connection,
onChange: (features: LabPreviewFeature[]) => void
) =>
createCollection<LabPreviewFeature[]>(
"_labFeatures",
fetchLabFeaturesCollection,
subscribeLabUpdates,
conn,
onChange
);

View File

@@ -1,25 +1,27 @@
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant, PanelInfo } from "../types"; import type { HomeAssistant, PanelInfo } from "../types";
/** Panel to show when no panel is picked. */ /** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "lovelace"; export const DEFAULT_PANEL = "lovelace";
export const getLegacyDefaultPanelUrlPath = (): string | null => { export const getStorageDefaultPanelUrlPath = (): string => {
const defaultPanel = window.localStorage.getItem("defaultPanel"); const defaultPanel = window.localStorage.getItem("defaultPanel");
return defaultPanel ? JSON.parse(defaultPanel) : null;
return defaultPanel ? JSON.parse(defaultPanel) : DEFAULT_PANEL;
}; };
export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => export const setDefaultPanel = (
hass.userData?.default_panel || element: HTMLElement,
hass.systemData?.default_panel || urlPath: string
getLegacyDefaultPanelUrlPath() || ): void => {
DEFAULT_PANEL; fireEvent(element, "hass-default-panel", { defaultPanel: urlPath });
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
const panel = getDefaultPanelUrlPath(hass);
return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
}; };
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo =>
hass.panels[hass.defaultPanel]
? hass.panels[hass.defaultPanel]
: hass.panels[DEFAULT_PANEL];
export const getPanelNameTranslationKey = (panel: PanelInfo) => { export const getPanelNameTranslationKey = (panel: PanelInfo) => {
if (panel.url_path === "lovelace") { if (panel.url_path === "lovelace") {
return "panel.states" as const; return "panel.states" as const;

View File

@@ -222,7 +222,6 @@ export interface StopAction extends BaseAction {
export interface SequenceAction extends BaseAction { export interface SequenceAction extends BaseAction {
sequence: (ManualScriptConfig | Action)[]; sequence: (ManualScriptConfig | Action)[];
metadata?: {};
} }
export interface ParallelAction extends BaseAction { export interface ParallelAction extends BaseAction {
@@ -480,7 +479,6 @@ export const migrateAutomationAction = (
} }
if (typeof action === "object" && action !== null && "sequence" in action) { if (typeof action === "object" && action !== null && "sequence" in action) {
delete (action as SequenceAction).metadata;
for (const sequenceAction of (action as SequenceAction).sequence) { for (const sequenceAction of (action as SequenceAction).sequence) {
migrateAutomationAction(sequenceAction); migrateAutomationAction(sequenceAction);
} }

View File

@@ -28,7 +28,6 @@ export interface TodoItem {
status: TodoItemStatus | null; status: TodoItemStatus | null;
description?: string | null; description?: string | null;
due?: string | null; due?: string | null;
completed?: string | null;
} }
export const enum TodoListEntityFeature { export const enum TodoListEntityFeature {

View File

@@ -72,10 +72,8 @@ export type TranslationCategory =
| "system_health" | "system_health"
| "application_credentials" | "application_credentials"
| "issues" | "issues"
| "preview_features"
| "selector" | "selector"
| "services" | "services";
| "triggers";
export const subscribeTranslationPreferences = ( export const subscribeTranslationPreferences = (
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -1,20 +1,57 @@
import { mdiMapClock, mdiShape } from "@mdi/js"; import {
mdiAvTimer,
mdiCalendar,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiMapClock,
mdiMapMarker,
mdiMapMarkerRadius,
mdiMessageAlert,
mdiMicrophoneMessage,
mdiNfcVariant,
mdiNumeric,
mdiShape,
mdiStateMachine,
mdiSwapHorizontal,
mdiWeatherSunny,
mdiWebhook,
} from "@mdi/js";
import { computeDomain } from "../common/entity/compute_domain"; import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type { import type {
AutomationElementGroupCollection, AutomationElementGroupCollection,
Trigger, Trigger,
TriggerList, TriggerList,
} from "./automation"; } from "./automation";
import type { Selector, TargetSelector } from "./selector";
export const TRIGGER_ICONS = {
calendar: mdiCalendar,
device: mdiDevices,
event: mdiGestureDoubleTap,
state: mdiStateMachine,
geo_location: mdiMapMarker,
homeassistant: mdiHomeAssistant,
mqtt: mdiSwapHorizontal,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
conversation: mdiMicrophoneMessage,
tag: mdiNfcVariant,
template: mdiCodeBraces,
time: mdiClockOutline,
time_pattern: mdiAvTimer,
webhook: mdiWebhook,
persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius,
list: mdiFormatListBulleted,
};
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
{ {
groups: { groups: {
device: {}, device: {},
dynamicGroups: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: { time_location: {
icon: mdiMapClock, icon: mdiMapClock,
@@ -46,33 +83,3 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
export const isTriggerList = (trigger: Trigger): trigger is TriggerList => export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
"triggers" in trigger; "triggers" in trigger;
export interface TriggerDescription {
target?: TargetSelector["target"];
fields: Record<
string,
{
example?: string | boolean | number;
default?: unknown;
required?: boolean;
selector?: Selector;
context?: Record<string, string>;
}
>;
}
export type TriggerDescriptions = Record<string, TriggerDescription>;
export const subscribeTriggers = (
hass: HomeAssistant,
callback: (triggers: TriggerDescriptions) => void
) =>
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
type: "trigger_platforms/subscribe",
});
export const getTriggerDomain = (trigger: string) =>
trigger.includes(".") ? computeDomain(trigger) : trigger;
export const getTriggerObjectId = (trigger: string) =>
trigger.includes(".") ? computeObjectId(trigger) : "_";

View File

@@ -2,8 +2,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-wa-dialog"; import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-formfield"; import "../../components/ha-formfield";
import "../../components/ha-switch"; import "../../components/ha-switch";
import "../../components/ha-button"; import "../../components/ha-button";
@@ -29,8 +28,6 @@ class DialogConfigEntrySystemOptions extends LitElement {
@state() private _submitting = false; @state() private _submitting = false;
@state() private _open = false;
public async showDialog( public async showDialog(
params: ConfigEntrySystemOptionsDialogParams params: ConfigEntrySystemOptionsDialogParams
): Promise<void> { ): Promise<void> {
@@ -38,14 +35,9 @@ class DialogConfigEntrySystemOptions extends LitElement {
this._error = undefined; this._error = undefined;
this._disableNewEntities = params.entry.pref_disable_new_entities; this._disableNewEntities = params.entry.pref_disable_new_entities;
this._disablePolling = params.entry.pref_disable_polling; this._disablePolling = params.entry.pref_disable_polling;
this._open = true;
} }
public closeDialog(): void { public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._error = ""; this._error = "";
this._params = undefined; this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -57,19 +49,18 @@ class DialogConfigEntrySystemOptions extends LitElement {
} }
return html` return html`
<ha-wa-dialog <ha-dialog
.hass=${this.hass} open
.open=${this._open} @closed=${this.closeDialog}
header-title=${this.hass.localize( .heading=${createCloseHeading(
"ui.dialogs.config_entry_system_options.title", this.hass,
{ this.hass.localize("ui.dialogs.config_entry_system_options.title", {
integration: integration:
this.hass.localize( this.hass.localize(
`component.${this._params.entry.domain}.title` `component.${this._params.entry.domain}.title`
) || this._params.entry.domain, ) || this._params.entry.domain,
} })
)} )}
@closed=${this._dialogClosed}
> >
${this._error ? html` <div class="error">${this._error}</div> ` : ""} ${this._error ? html` <div class="error">${this._error}</div> ` : ""}
<ha-formfield <ha-formfield
@@ -91,10 +82,10 @@ class DialogConfigEntrySystemOptions extends LitElement {
</p>`} </p>`}
> >
<ha-switch <ha-switch
autofocus
.checked=${!this._disableNewEntities} .checked=${!this._disableNewEntities}
@change=${this._disableNewEntitiesChanged} @change=${this._disableNewEntitiesChanged}
.disabled=${this._submitting} .disabled=${this._submitting}
dialogInitialFocus
></ha-switch> ></ha-switch>
</ha-formfield> </ha-formfield>
@@ -122,27 +113,22 @@ class DialogConfigEntrySystemOptions extends LitElement {
.disabled=${this._submitting} .disabled=${this._submitting}
></ha-switch> ></ha-switch>
</ha-formfield> </ha-formfield>
<ha-button
<ha-dialog-footer slot="footer"> appearance="plain"
<ha-button slot="primaryAction"
appearance="plain" @click=${this.closeDialog}
slot="secondaryAction" .disabled=${this._submitting}
@click=${this.closeDialog} >
.disabled=${this._submitting} ${this.hass.localize("ui.common.cancel")}
> </ha-button>
${this.hass.localize("ui.common.cancel")} <ha-button
</ha-button> slot="primaryAction"
<ha-button @click=${this._updateEntry}
slot="primaryAction" .disabled=${this._submitting}
@click=${this._updateEntry} >
.disabled=${this._submitting} ${this.hass.localize("ui.dialogs.config_entry_system_options.update")}
> </ha-button>
${this.hass.localize( </ha-dialog>
"ui.dialogs.config_entry_system_options.update"
)}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`; `;
} }

View File

@@ -1,152 +0,0 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-alert";
import "../../components/ha-icon";
import "../../components/ha-list-item";
import "../../components/ha-spinner";
import type {
ExternalEntityAddToActions,
ExternalEntityAddToAction,
} from "../../external_app/external_messaging";
import { showToast } from "../../util/toast";
import type { HomeAssistant } from "../../types";
@customElement("ha-more-info-add-to")
export class HaMoreInfoAddTo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@state() private _externalActions?: ExternalEntityAddToActions = {
actions: [],
};
@state() private _loading = true;
private async _loadExternalActions() {
if (this.hass.auth.external?.config.hasEntityAddTo) {
this._externalActions =
await this.hass.auth.external?.sendMessage<"entity/add_to/get_actions">(
{
type: "entity/add_to/get_actions",
payload: { entity_id: this.entityId },
}
);
}
}
private async _actionSelected(ev: CustomEvent) {
const action = (ev.currentTarget as any)
.action as ExternalEntityAddToAction;
if (!action.enabled) {
return;
}
try {
await this.hass.auth.external!.fireMessage({
type: "entity/add_to",
payload: {
entity_id: this.entityId,
app_payload: action.app_payload,
},
});
} catch (err: any) {
showToast(this, {
message: this.hass.localize(
"ui.dialogs.more_info_control.add_to.action_failed",
{
error: err.message || err,
}
),
});
}
}
protected async firstUpdated() {
await this._loadExternalActions();
this._loading = false;
}
protected render() {
if (this._loading) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
if (!this._externalActions?.actions.length) {
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.no_actions"
)}
</ha-alert>
`;
}
return html`
<div class="actions-list">
${this._externalActions.actions.map(
(action) => html`
<ha-list-item
graphic="icon"
.disabled=${!action.enabled}
.action=${action}
.twoline=${!!action.details}
@click=${this._actionSelected}
>
<span>${action.name}</span>
${action.details
? html`<span slot="secondary">${action.details}</span>`
: nothing}
<ha-icon slot="graphic" .icon=${action.mdi_icon}></ha-icon>
</ha-list-item>
`
)}
</div>
`;
}
static styles = css`
:host {
display: block;
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: var(--ha-space-8);
}
.actions-list {
display: flex;
flex-direction: column;
}
ha-list-item {
cursor: pointer;
}
ha-list-item[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
ha-icon {
display: flex;
align-items: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-add-to": HaMoreInfoAddTo;
}
}

View File

@@ -8,7 +8,6 @@ import {
mdiPencil, mdiPencil,
mdiPencilOff, mdiPencilOff,
mdiPencilOutline, mdiPencilOutline,
mdiPlusBoxMultipleOutline,
mdiTransitConnectionVariant, mdiTransitConnectionVariant,
} from "@mdi/js"; } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
@@ -50,7 +49,7 @@ import { lightSupportsFavoriteColors } from "../../data/light";
import type { ItemType } from "../../data/search"; import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search"; import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor"; import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
import "../../state-summary/state-card-content"; import "../../state-summary/state-card-content";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { import {
@@ -61,7 +60,6 @@ import {
computeShowLogBookComponent, computeShowLogBookComponent,
} from "./const"; } from "./const";
import "./controls/more-info-default"; import "./controls/more-info-default";
import "./ha-more-info-add-to";
import "./ha-more-info-history-and-logbook"; import "./ha-more-info-history-and-logbook";
import "./ha-more-info-info"; import "./ha-more-info-info";
import "./ha-more-info-settings"; import "./ha-more-info-settings";
@@ -75,7 +73,7 @@ export interface MoreInfoDialogParams {
data?: Record<string, any>; data?: Record<string, any>;
} }
type View = "info" | "history" | "settings" | "related" | "add_to"; type View = "info" | "history" | "settings" | "related";
interface ChildView { interface ChildView {
viewTag: string; viewTag: string;
@@ -196,10 +194,6 @@ export class MoreInfoDialog extends LitElement {
); );
} }
private _shouldShowAddEntityTo(): boolean {
return !!this.hass.auth.external?.config.hasEntityAddTo;
}
private _getDeviceId(): string | null { private _getDeviceId(): string | null {
const entity = this.hass.entities[this._entityId!] as const entity = this.hass.entities[this._entityId!] as
| EntityRegistryEntry | EntityRegistryEntry
@@ -301,11 +295,6 @@ export class MoreInfoDialog extends LitElement {
this._setView("related"); this._setView("related");
} }
private _goToAddEntityTo(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) return;
this._setView("add_to");
}
private _breadcrumbClick(ev: Event) { private _breadcrumbClick(ev: Event) {
ev.stopPropagation(); ev.stopPropagation();
this._setView("related"); this._setView("related");
@@ -532,22 +521,6 @@ export class MoreInfoDialog extends LitElement {
.path=${mdiInformationOutline} .path=${mdiInformationOutline}
></ha-svg-icon> ></ha-svg-icon>
</ha-list-item> </ha-list-item>
${this._shouldShowAddEntityTo()
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._goToAddEntityTo}
>
${this.hass.localize(
"ui.dialogs.more_info_control.add_entity_to"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPlusBoxMultipleOutline}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
</ha-button-menu> </ha-button-menu>
` `
: nothing} : nothing}
@@ -640,14 +613,7 @@ export class MoreInfoDialog extends LitElement {
: "entity"} : "entity"}
></ha-related-items> ></ha-related-items>
` `
: this._currView === "add_to" : nothing
? html`
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
></ha-more-info-add-to>
`
: nothing
)} )}
</div> </div>
` `
@@ -707,9 +673,14 @@ export class MoreInfoDialog extends LitElement {
static get styles() { static get styles() {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: max(
var(--ha-space-10),
var(--safe-area-inset-top, var(--ha-space-0))
);
--dialog-content-padding: 0; --dialog-content-padding: 0;
} }
@@ -732,6 +703,13 @@ export class MoreInfoDialog extends LitElement {
display: block; display: block;
} }
@media all and (max-width: 450px), all and (max-height: 500px) {
/* When in fullscreen dialog should be attached to top */
ha-dialog {
--dialog-surface-margin-top: var(--ha-space-0);
}
}
@media all and (min-width: 600px) and (min-height: 501px) { @media all and (min-width: 600px) and (min-height: 501px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: 580px; --mdc-dialog-min-width: 580px;

View File

@@ -152,18 +152,10 @@ export class MoreInfoHistory extends LitElement {
} }
} }
private _setUpdateTimer() { private _setRedrawTimer() {
// redraw the graph every minute to update the time axis
clearInterval(this._interval); clearInterval(this._interval);
this._interval = window.setInterval(() => { this._interval = window.setInterval(() => this._redrawGraph(), 1000 * 60);
// If using statistics, refresh the data
if (this._statistics) {
this._fetchStatistics();
}
// If using history, redraw the graph to update the time axis
if (this._stateHistory) {
this._redrawGraph();
}
}, 1000 * 60);
} }
private async _getStatisticsMetaData(statisticIds: string[] | undefined) { private async _getStatisticsMetaData(statisticIds: string[] | undefined) {
@@ -178,30 +170,6 @@ export class MoreInfoHistory extends LitElement {
return statisticsMetaData; return statisticsMetaData;
} }
private async _fetchStatistics(): Promise<boolean> {
// Fire off the metadata and fetch at the same time
// to avoid waiting in sequence so the UI responds
// faster.
const _metadata = this._getStatisticsMetaData([this.entityId]);
const _statistics = fetchStatistics(
this.hass!,
subHours(new Date(), 24),
undefined,
[this.entityId],
"5minute",
undefined,
statTypes
);
const [metadata, statistics] = await Promise.all([_metadata, _statistics]);
if (metadata && Object.keys(metadata).length) {
this._metadata = metadata;
this._statistics = statistics;
this._statNames = { [this.entityId]: "" };
return true;
}
return false;
}
private async _getStateHistory(): Promise<void> { private async _getStateHistory(): Promise<void> {
if ( if (
isComponentLoaded(this.hass, "recorder") && isComponentLoaded(this.hass, "recorder") &&
@@ -212,10 +180,27 @@ export class MoreInfoHistory extends LitElement {
// has not opted into statistics so there is no need to check as it // has not opted into statistics so there is no need to check as it
// requires another round-trip to the server. // requires another round-trip to the server.
if (stateObj && stateObj.attributes.state_class) { if (stateObj && stateObj.attributes.state_class) {
const hasStatistics = await this._fetchStatistics(); // Fire off the metadata and fetch at the same time
if (hasStatistics) { // to avoid waiting in sequence so the UI responds
// Using statistics, set up refresh timer // faster.
this._setUpdateTimer(); const _metadata = this._getStatisticsMetaData([this.entityId]);
const _statistics = fetchStatistics(
this.hass!,
subHours(new Date(), 24),
undefined,
[this.entityId],
"5minute",
undefined,
statTypes
);
const [metadata, statistics] = await Promise.all([
_metadata,
_statistics,
]);
if (metadata && Object.keys(metadata).length) {
this._metadata = metadata;
this._statistics = statistics;
this._statNames = { [this.entityId]: "" };
return; return;
} }
} }
@@ -253,7 +238,7 @@ export class MoreInfoHistory extends LitElement {
this._error = err; this._error = err;
return undefined; return undefined;
}); });
this._setUpdateTimer(); this._setRedrawTimer();
} }
static styles = [ static styles = [

View File

@@ -46,11 +46,7 @@ import { getPanelNameTranslationKey } from "../../data/panel";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage"; import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { configSections } from "../../panels/config/ha-panel-config"; import { configSections } from "../../panels/config/ha-panel-config";
import { HaFuse } from "../../resources/fuse"; import { HaFuse } from "../../resources/fuse";
import { import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
} from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer"; import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url"; import { brandsUrl } from "../../util/brands-url";
@@ -990,7 +986,6 @@ export class QuickBar extends LitElement {
return [ return [
haStyleScrollbar, haStyleScrollbar,
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-list { ha-list {
position: relative; position: relative;
@@ -1015,9 +1010,9 @@ export class QuickBar extends LitElement {
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 800px; --mdc-dialog-max-width: 800px;
--mdc-dialog-min-width: 500px; --mdc-dialog-min-width: 500px;
--mdc-dialog-max-height: calc( --dialog-surface-position: fixed;
100vh - var(--ha-space-18) - var(--safe-area-inset-y) --dialog-surface-top: var(--ha-space-10);
); --mdc-dialog-max-height: calc(100% - var(--ha-space-18));
} }
} }

View File

@@ -20,7 +20,6 @@ import {
} from "../../data/frontend"; } from "../../data/frontend";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box"; import { showConfirmationDialog } from "../generic/show-dialog-box";
import { getDefaultPanelUrlPath } from "../../data/panel";
@customElement("dialog-edit-sidebar") @customElement("dialog-edit-sidebar")
class DialogEditSidebar extends LitElement { class DialogEditSidebar extends LitElement {
@@ -95,27 +94,14 @@ class DialogEditSidebar extends LitElement {
const panels = this._panels(this.hass.panels); const panels = this._panels(this.hass.panels);
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const [beforeSpacer, afterSpacer] = computePanels( const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels, this.hass.panels,
defaultPanel, this.hass.defaultPanel,
this._order, this._order,
this._hidden, this._hidden,
this.hass.locale this.hass.locale
); );
// Add default hidden panels that are missing in hidden
for (const panel of panels) {
if (
panel.default_visible === false &&
!this._order.includes(panel.url_path) &&
!this._hidden.includes(panel.url_path)
) {
this._hidden.push(panel.url_path);
}
}
const items = [ const items = [
...beforeSpacer, ...beforeSpacer,
...panels.filter((panel) => this._hidden!.includes(panel.url_path)), ...panels.filter((panel) => this._hidden!.includes(panel.url_path)),
@@ -123,12 +109,12 @@ class DialogEditSidebar extends LitElement {
].map((panel) => ({ ].map((panel) => ({
value: panel.url_path, value: panel.url_path,
label: label:
panel.url_path === defaultPanel panel.url_path === this.hass.defaultPanel
? panel.title || this.hass.localize("panel.states") ? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title || "?", : this.hass.localize(`panel.${panel.title}`) || panel.title || "?",
icon: panel.icon || undefined, icon: panel.icon || undefined,
iconPath: iconPath:
panel.url_path === defaultPanel && !panel.icon panel.url_path === this.hass.defaultPanel && !panel.icon
? PANEL_ICONS.lovelace ? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS : panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path] ? PANEL_ICONS[panel.url_path]

View File

@@ -36,13 +36,6 @@ interface EMOutgoingMessageConfigGet extends EMMessage {
type: "config/get"; type: "config/get";
} }
interface EMOutgoingMessageEntityAddToGetActions extends EMMessage {
type: "entity/add_to/get_actions";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageBarCodeScan extends EMMessage { interface EMOutgoingMessageBarCodeScan extends EMMessage {
type: "bar_code/scan"; type: "bar_code/scan";
payload: { payload: {
@@ -82,10 +75,6 @@ interface EMOutgoingMessageWithAnswer {
request: EMOutgoingMessageConfigGet; request: EMOutgoingMessageConfigGet;
response: ExternalConfig; response: ExternalConfig;
}; };
"entity/add_to/get_actions": {
request: EMOutgoingMessageEntityAddToGetActions;
response: ExternalEntityAddToActions;
};
} }
interface EMOutgoingMessageExoplayerPlayHLS extends EMMessage { interface EMOutgoingMessageExoplayerPlayHLS extends EMMessage {
@@ -168,14 +157,6 @@ interface EMOutgoingMessageThreadStoreInPlatformKeychain extends EMMessage {
}; };
} }
interface EMOutgoingMessageAddEntityTo extends EMMessage {
type: "entity/add_to";
payload: {
entity_id: string;
app_payload: string; // Opaque string received from get_actions
};
}
type EMOutgoingMessageWithoutAnswer = type EMOutgoingMessageWithoutAnswer =
| EMMessageResultError | EMMessageResultError
| EMMessageResultSuccess | EMMessageResultSuccess
@@ -196,8 +177,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageThemeUpdate | EMOutgoingMessageThemeUpdate
| EMOutgoingMessageThreadStoreInPlatformKeychain | EMOutgoingMessageThreadStoreInPlatformKeychain
| EMOutgoingMessageImprovScan | EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice | EMOutgoingMessageImprovConfigureDevice;
| EMOutgoingMessageAddEntityTo;
export interface EMIncomingMessageRestart { export interface EMIncomingMessageRestart {
id: number; id: number;
@@ -313,31 +293,18 @@ type EMIncomingMessage =
type EMIncomingMessageHandler = (msg: EMIncomingMessageCommands) => boolean; type EMIncomingMessageHandler = (msg: EMIncomingMessageCommands) => boolean;
export interface ExternalConfig { export interface ExternalConfig {
hasSettingsScreen?: boolean; hasSettingsScreen: boolean;
hasSidebar?: boolean; hasSidebar: boolean;
canWriteTag?: boolean; canWriteTag: boolean;
hasExoPlayer?: boolean; hasExoPlayer: boolean;
canCommissionMatter?: boolean; canCommissionMatter: boolean;
canImportThreadCredentials?: boolean; canImportThreadCredentials: boolean;
canTransferThreadCredentialsToKeychain?: boolean; canTransferThreadCredentialsToKeychain: boolean;
hasAssist?: boolean; hasAssist: boolean;
hasBarCodeScanner?: number; hasBarCodeScanner: number;
canSetupImprov?: boolean; canSetupImprov: boolean;
downloadFileSupported?: boolean; downloadFileSupported: boolean;
appVersion?: string; appVersion: string;
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
}
export interface ExternalEntityAddToAction {
enabled: boolean;
name: string; // Translated name of the action to be displayed in the UI
details?: string; // Optional translated details of the action to be displayed in the UI
mdi_icon: string; // MDI icon name to be displayed in the UI (e.g., "mdi:car")
app_payload: string; // Opaque string to be sent back when the action is selected
}
export interface ExternalEntityAddToActions {
actions: ExternalEntityAddToAction[];
} }
export class ExternalMessaging { export class ExternalMessaging {

View File

@@ -20,44 +20,23 @@
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<%= renderTemplate("_style_base.html.template") %> <%= renderTemplate("_style_base.html.template") %>
<style> <style>
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-group(launch-screen) {
animation-duration: var(--ha-animation-base-duration, 350ms);
animation-timing-function: ease-out;
}
::view-transition-old(launch-screen) {
animation: fade-out var(--ha-animation-base-duration, 350ms) ease-out;
}
html { html {
background-color: var(--primary-background-color, #fafafa); background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121); color: var(--primary-text-color, #212121);
height: 100vh; height: 100vh;
} }
@media (prefers-color-scheme: dark) {
html {
background-color: var(--primary-background-color, #111111);
color: var(--primary-text-color, #e1e1e1);
}
}
#ha-launch-screen { #ha-launch-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
view-transition-name: launch-screen;
background-color: var(--primary-background-color, #fafafa);
z-index: 100;
}
#ha-launch-screen.removing {
opacity: 0;
} }
#ha-launch-screen svg { #ha-launch-screen svg {
width: 112px; width: 112px;
@@ -80,14 +59,6 @@
opacity: .66; opacity: .66;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html {
background-color: var(--primary-background-color, #111111);
color: var(--primary-text-color, #e1e1e1);
}
/* body selector to avoid minification causing bad jinja2 */
body #ha-launch-screen {
background-color: var(--primary-background-color, #111111);
}
.ohf-logo { .ohf-logo {
filter: invert(1); filter: invert(1);
} }

View File

@@ -1,5 +1,5 @@
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event"; import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
@@ -46,13 +46,10 @@ export class HomeAssistantMain extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
const sidebarNarrow = this._sidebarNarrow || this._externalSidebar; const sidebarNarrow = this._sidebarNarrow || this._externalSidebar;
const isPanelReady =
this.hass.panels && this.hass.userData && this.hass.systemData;
return html` return html`
<ha-drawer <ha-drawer
.type=${sidebarNarrow ? "modal" : ""} .type=${sidebarNarrow ? "modal" : ""}
.open=${sidebarNarrow ? this._drawerOpen : false} .open=${sidebarNarrow ? this._drawerOpen : undefined}
.direction=${computeRTLDirection(this.hass)} .direction=${computeRTLDirection(this.hass)}
@MDCDrawer:closed=${this._drawerClosed} @MDCDrawer:closed=${this._drawerClosed}
> >
@@ -62,14 +59,12 @@ export class HomeAssistantMain extends LitElement {
.route=${this.route} .route=${this.route}
.alwaysExpand=${sidebarNarrow || this.hass.dockedSidebar === "docked"} .alwaysExpand=${sidebarNarrow || this.hass.dockedSidebar === "docked"}
></ha-sidebar> ></ha-sidebar>
${isPanelReady <partial-panel-resolver
? html`<partial-panel-resolver .narrow=${this.narrow}
.narrow=${this.narrow} .hass=${this.hass}
.hass=${this.hass} .route=${this.route}
.route=${this.route} slot="appContent"
slot="appContent" ></partial-panel-resolver>
></partial-panel-resolver>`
: nothing}
</ha-drawer> </ha-drawer>
`; `;
} }

View File

@@ -1,10 +1,10 @@
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { html } from "lit"; import { html } from "lit";
import { customElement, state } from "lit/decorators"; import { customElement, state } from "lit/decorators";
import { storage } from "../common/decorators/storage"; import type { Connection } from "home-assistant-js-websocket";
import { isNavigationClick } from "../common/dom/is-navigation-click"; import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
import { getStorageDefaultPanelUrlPath } from "../data/panel";
import type { WindowWithPreloads } from "../data/preloads"; import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder"; import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder"; import { getRecorderInfo } from "../data/recorder";
@@ -23,6 +23,7 @@ import {
} from "../util/register-service-worker"; } from "../util/register-service-worker";
import "./ha-init-page"; import "./ha-init-page";
import "./home-assistant-main"; import "./home-assistant-main";
import { storage } from "../common/decorators/storage";
const useHash = __DEMO__; const useHash = __DEMO__;
const curPath = () => const curPath = () =>
@@ -52,6 +53,11 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
super(); super();
const path = curPath(); const path = curPath();
if (["", "/"].includes(path)) {
navigate(`/${getStorageDefaultPanelUrlPath()}${location.search}`, {
replace: true,
});
}
this._route = { this._route = {
prefix: "", prefix: "",
path, path,

View File

@@ -35,7 +35,6 @@ const COMPONENTS = {
light: () => import("../panels/light/ha-panel-light"), light: () => import("../panels/light/ha-panel-light"),
security: () => import("../panels/security/ha-panel-security"), security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"), climate: () => import("../panels/climate/ha-panel-climate"),
home: () => import("../panels/home/ha-panel-home"),
}; };
@customElement("partial-panel-resolver") @customElement("partial-panel-resolver")

View File

@@ -5,7 +5,6 @@ import { atLeastVersion } from "../common/config/version";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import "../components/ha-card"; import "../components/ha-card";
import { haStyle } from "../resources/styles"; import { haStyle } from "../resources/styles";
import { documentationUrl } from "../util/documentation-url";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./hass-subpage"; import "./hass-subpage";
@@ -58,7 +57,7 @@ class SupervisorErrorScreen extends LitElement {
</li> </li>
<li> <li>
<a <a
href=${documentationUrl(this.hass, "/help/")} href="https://www.home-assistant.io/help/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >

View File

@@ -1,133 +0,0 @@
import type { ReactiveElement } from "lit";
import type { HomeAssistant } from "../types";
import {
setupMediaQueryListeners,
setupTimeListeners,
} from "../common/condition/listeners";
import type { Condition } from "../panels/lovelace/common/validate-condition";
type Constructor<T> = abstract new (...args: any[]) => T;
/**
* Base config type that can be used with conditional listeners
*/
export interface ConditionalConfig {
visibility?: Condition[];
[key: string]: any;
}
/**
* Mixin to handle conditional listeners for visibility control
*
* Provides lifecycle management for listeners that control conditional
* visibility of components.
*
* Usage:
* 1. Extend your component with ConditionalListenerMixin<YourConfigType>(ReactiveElement)
* 2. Ensure component has config.visibility or _config.visibility property with conditions
* 3. Ensure component has _updateVisibility() or _updateElement() method
* 4. Override setupConditionalListeners() if custom behavior needed (e.g., filter conditions)
*
* The mixin automatically:
* - Sets up listeners when component connects to DOM
* - Cleans up listeners when component disconnects from DOM
* - Handles conditional visibility based on defined conditions
*/
export const ConditionalListenerMixin = <
TConfig extends ConditionalConfig = ConditionalConfig,
>(
superClass: Constructor<ReactiveElement>
) => {
abstract class ConditionalListenerClass extends superClass {
private __listeners: (() => void)[] = [];
protected _config?: TConfig;
public config?: TConfig;
public hass?: HomeAssistant;
protected _updateElement?(config: TConfig): void;
protected _updateVisibility?(conditionsMet?: boolean): void;
public connectedCallback() {
super.connectedCallback();
this.setupConditionalListeners();
}
public disconnectedCallback() {
super.disconnectedCallback();
this.clearConditionalListeners();
}
/**
* Clear conditional listeners
*
* This method is called when the component is disconnected from the DOM.
* It clears all the listeners that were set up by the setupConditionalListeners() method.
*/
protected clearConditionalListeners(): void {
this.__listeners.forEach((unsub) => unsub());
this.__listeners = [];
}
/**
* Add a conditional listener to the list of listeners
*
* This method is called when a new listener is added.
* It adds the listener to the list of listeners.
*
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
* @returns void
*/
protected addConditionalListener(unsubscribe: () => void): void {
this.__listeners.push(unsubscribe);
}
/**
* Setup conditional listeners for visibility control
*
* Default implementation:
* - Checks config.visibility or _config.visibility for conditions (if not provided)
* - Sets up appropriate listeners based on condition types
* - Calls _updateVisibility() or _updateElement() when conditions change
*
* Override this method to customize behavior (e.g., filter conditions first)
* and call super.setupConditionalListeners(customConditions) to reuse the base implementation
*
* @param conditions - Optional conditions array. If not provided, will check config.visibility or _config.visibility
*/
protected setupConditionalListeners(conditions?: Condition[]): void {
const config = this.config || this._config;
const finalConditions = conditions || config?.visibility;
if (!finalConditions || !this.hass) {
return;
}
const onUpdate = (conditionsMet: boolean) => {
if (this._updateVisibility) {
this._updateVisibility(conditionsMet);
} else if (this._updateElement && config) {
this._updateElement(config);
}
};
setupMediaQueryListeners(
finalConditions,
this.hass,
(unsub) => this.addConditionalListener(unsub),
onUpdate
);
setupTimeListeners(
finalConditions,
this.hass,
(unsub) => this.addConditionalListener(unsub),
onUpdate
);
}
}
return ConditionalListenerClass;
};

View File

@@ -4,7 +4,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-card"; import "../components/ha-card";
import { documentationUrl } from "../util/documentation-url";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { showAppDialog } from "./dialogs/show-app-dialog"; import { showAppDialog } from "./dialogs/show-app-dialog";
import { showCommunityDialog } from "./dialogs/show-community-dialog"; import { showCommunityDialog } from "./dialogs/show-community-dialog";
@@ -23,10 +22,7 @@ class OnboardingWelcomeLinks extends LitElement {
return html`<a return html`<a
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
href=${documentationUrl( href="https://www.home-assistant.io/blog/2016/01/19/perfect-home-automation/"
this.hass,
"/blog/2016/01/19/perfect-home-automation/"
)}
> >
<onboarding-welcome-link <onboarding-welcome-link
noninteractive noninteractive

View File

@@ -80,12 +80,10 @@ class DialogCalendarEventDetail extends LitElement {
${this._data!.rrule ${this._data!.rrule
? this._renderRRuleAsText(this._data.rrule) ? this._renderRRuleAsText(this._data.rrule)
: ""} : ""}
${this._data.location
? html`${this._data.location} <br />`
: nothing}
${this._data.description ${this._data.description
? html`<br /> ? html`<br />
<div class="description">${this._data.description}</div>` <div class="description">${this._data.description}</div>
<br />`
: nothing} : nothing}
</div> </div>
</div> </div>
@@ -243,7 +241,7 @@ class DialogCalendarEventDetail extends LitElement {
haStyleDialog, haStyleDialog,
css` css`
state-info { state-info {
margin-top: 24px; line-height: 40px;
} }
ha-svg-icon { ha-svg-icon {
width: 40px; width: 40px;

View File

@@ -63,8 +63,6 @@ class DialogCalendarEventEditor extends LitElement {
@state() private _description? = ""; @state() private _description? = "";
@state() private _location? = "";
@state() private _rrule?: string; @state() private _rrule?: string;
@state() private _allDay = false; @state() private _allDay = false;
@@ -81,8 +79,6 @@ class DialogCalendarEventEditor extends LitElement {
// timezone, but floating without a timezone. // timezone, but floating without a timezone.
private _timeZone?: string; private _timeZone?: string;
private _hasLocation = false;
public showDialog(params: CalendarEventEditDialogParams): void { public showDialog(params: CalendarEventEditDialogParams): void {
this._error = undefined; this._error = undefined;
this._info = undefined; this._info = undefined;
@@ -103,10 +99,6 @@ class DialogCalendarEventEditor extends LitElement {
this._allDay = isDate(entry.dtstart); this._allDay = isDate(entry.dtstart);
this._summary = entry.summary; this._summary = entry.summary;
this._description = entry.description; this._description = entry.description;
if (entry.location) {
this._hasLocation = true;
this._location = entry.location;
}
this._rrule = entry.rrule; this._rrule = entry.rrule;
if (this._allDay) { if (this._allDay) {
this._dtstart = new Date(entry.dtstart + "T00:00:00"); this._dtstart = new Date(entry.dtstart + "T00:00:00");
@@ -138,8 +130,6 @@ class DialogCalendarEventEditor extends LitElement {
this._dtend = undefined; this._dtend = undefined;
this._summary = ""; this._summary = "";
this._description = ""; this._description = "";
this._location = "";
this._hasLocation = false;
this._rrule = undefined; this._rrule = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@@ -191,15 +181,6 @@ class DialogCalendarEventEditor extends LitElement {
.validationMessage=${this.hass.localize("ui.common.error_required")} .validationMessage=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus dialogInitialFocus
></ha-textfield> ></ha-textfield>
<ha-textfield
class="location"
name="location"
.label=${this.hass.localize(
"ui.components.calendar.event.location"
)}
.value=${this._location}
@change=${this._handleLocationChanged}
></ha-textfield>
<ha-textarea <ha-textarea
class="description" class="description"
name="description" name="description"
@@ -345,25 +326,12 @@ class DialogCalendarEventEditor extends LitElement {
this._description = ev.target.value; this._description = ev.target.value;
} }
private _handleLocationChanged(ev: Event) {
this._location = (ev.target as HTMLInputElement).value;
}
private _handleRRuleChanged(ev) { private _handleRRuleChanged(ev) {
this._rrule = ev.detail.value; this._rrule = ev.detail.value;
} }
private _allDayToggleChanged(ev) { private _allDayToggleChanged(ev) {
this._allDay = ev.target.checked; this._allDay = ev.target.checked;
// When switching to all-day mode, normalize dates to midnight so time portions don't interfere with date comparisons
if (this._allDay && this._dtstart && this._dtend) {
this._dtstart = new Date(
formatDate(this._dtstart, this._timeZone!) + "T00:00:00"
);
this._dtend = new Date(
formatDate(this._dtend, this._timeZone!) + "T00:00:00"
);
}
} }
private _startDateChanged(ev: CustomEvent) { private _startDateChanged(ev: CustomEvent) {
@@ -422,7 +390,6 @@ class DialogCalendarEventEditor extends LitElement {
const data: CalendarEventMutableParams = { const data: CalendarEventMutableParams = {
summary: this._summary, summary: this._summary,
description: this._description, description: this._description,
location: this._location || (this._hasLocation ? "" : undefined),
rrule: this._rrule || undefined, rrule: this._rrule || undefined,
dtstart: "", dtstart: "",
dtend: "", dtend: "",

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