Compare commits

...

61 Commits

Author SHA1 Message Date
Petar Petrov
882682d0ae fix import 2025-11-12 16:48:25 +02:00
Petar Petrov
ec22937e71 fix import 2025-11-12 11:50:40 +02:00
Petar Petrov
53a87278dd PR comments 2025-11-12 11:41:06 +02:00
Petar Petrov
31383c114b Unify CalendarEventRestData and CalendarEventSubscriptionData
Both interfaces had identical structures, so unified them into a single
CalendarEventSubscriptionData interface that is used for both REST API
responses and WebSocket subscription data.

Changes:
- Removed CalendarEventRestData interface
- Updated fetchCalendarEvents to use CalendarEventSubscriptionData
- Added documentation clarifying the interface is used for both APIs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 16:29:50 +02:00
Petar Petrov
7e01777eaa Replace any types with proper TypeScript types
Added proper types for calendar event data:
- CalendarDateValue: Union type for date values (string | {dateTime} | {date})
- CalendarEventRestData: Interface for REST API event responses
- Updated fetchCalendarEvents to use CalendarEventRestData[]
- Updated CalendarEventSubscriptionData to use CalendarDateValue
- Updated getCalendarDate to use proper type guards with 'in' operator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 15:34:10 +02:00
Petar Petrov
9c64f5ac8b Move date normalization into normalizeSubscriptionEventData
The getCalendarDate helper is part of the normalization process and should be inside the normalization function. This makes normalizeSubscriptionEventData handle both REST API format (with dateTime/date objects) and subscription format (plain strings).

Changes:
- Moved getCalendarDate into normalizeSubscriptionEventData
- Updated CalendarEventSubscriptionData to accept string | any for start/end
- Made normalizeSubscriptionEventData return CalendarEvent | null for invalid dates
- Simplified fetchCalendarEvents to use the shared normalization
- Added null filtering in calendar card and panel event handlers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 15:32:31 +02:00
Petar Petrov
912e636207 Refactor fetchCalendarEvents to use shared normalization utility
Eliminated code duplication by reusing normalizeSubscriptionEventData() in
fetchCalendarEvents(). After extracting date strings from the REST API
response format, we now convert to a subscription-like format and pass
it to the shared utility.

This ensures consistent event normalization across both REST API and
WebSocket subscription code paths, reducing maintenance burden and
potential for divergence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 15:25:31 +02:00
Petar Petrov
43367350b7 Update src/panels/calendar/ha-panel-calendar.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 13:24:02 +02:00
Petar Petrov
64a25cf7f9 Update src/panels/lovelace/cards/hui-calendar-card.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 13:23:51 +02:00
Petar Petrov
6de8f47e24 Update src/panels/lovelace/cards/hui-calendar-card.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 13:23:42 +02:00
Petar Petrov
0427c17a76 Address additional review comments
Fixed remaining issues from code review:

1. **Added @state decorator to _errorCalendars**: Ensures proper reactivity
   in calendar card when errors occur or are cleared, triggering UI updates.

2. **Fixed error accumulation in panel calendar**: Panel now properly
   accumulates errors from multiple calendars similar to the card
   implementation, preventing previously failed calendars from being
   hidden when new errors occur.

3. **Removed duplicate subscription check**: Deleted redundant duplicate
   subscription prevention in _requestSelected() since
   _subscribeCalendarEvents() already handles this at lines 221-227.

Note: The [nitpick] comment about loading states during await is a
performance enhancement suggestion, not a required fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:14:30 +02:00
Petar Petrov
0052f14521 Extract event normalization to shared utility function
Reduced code duplication by extracting the calendar event normalization
logic from both hui-calendar-card.ts and ha-panel-calendar.ts into a
shared utility function in calendar.ts.

The normalizeSubscriptionEventData() function handles the conversion
from subscription format (start/end) to internal format (dtstart/dtend)
in a single, reusable location.

This improves maintainability by ensuring consistent event normalization
across all calendar components and reduces the risk of divergence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:04:04 +02:00
Petar Petrov
792274a82a Address PR review comments
Fixes based on Copilot review feedback:

1. **Fixed race conditions**: Made _unsubscribeAll() async and await it
   before creating new subscriptions to prevent old subscription events
   from updating UI after new subscriptions are created.

2. **Added error handling**: All unsubscribe operations now catch errors
   to handle cases where subscriptions may have already been closed.

3. **Fixed type safety**: Replaced 'any' type with proper
   CalendarEventSubscriptionData type and added interface definition
   for subscription response data structure.

4. **Improved error tracking**: Calendar card now accumulates errors from
   multiple calendars instead of only showing the last error.

5. **Prevented duplicate subscriptions**: Added checks to unsubscribe
   existing subscriptions before creating new ones in both
   _subscribeCalendarEvents and _requestSelected.

6. **Fixed null handling**: Properly convert null values to undefined
   for CalendarEventData fields to match expected types.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 12:58:32 +02:00
Petar Petrov
7b42b16de8 Fix calendar subscription data format mismatch
The websocket subscription returns event data with fields named start and end, but the frontend was expecting dtstart and dtend. This caused events to not display because the data wasn't being properly mapped.

Now properly transform the subscription response format:
- Subscription format: start/end/summary/description
- Internal format: dtstart/dtend/summary/description

This ensures both initial event loading and real-time updates work correctly.
2025-11-11 12:14:42 +02:00
Petar Petrov
fe98c0bdc0 Fix calendar events not loading on initial render
Remove premature subscription attempt in setConfig() that was failing because the date range wasn't available yet. The subscription now properly happens when the view-changed event fires from ha-full-calendar after initial render, which includes the necessary start/end dates.

This ensures calendar events load immediately when the component is first displayed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 12:05:38 +02:00
Petar Petrov
658955a1b9 Use websocket subscription for calendar events
Replace polling-based calendar event fetching with real-time websocket subscriptions. This leverages the new subscription API added in core to provide automatic updates when calendar events change, eliminating the need for periodic polling.

The subscription pattern follows the same approach used for todo items, with proper lifecycle management and cleanup.

Related: home-assistant/core#156340
Related: #27565

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:44:00 +02:00
ildar170975
9a627bdea7 Data tables: remove unneeded "direction: asc" lines (#27903)
* remove unneeded "direction: asc"

* remove unneeded "direction: asc"

* remove unneeded "direction: asc"
2025-11-11 08:14:03 +02:00
Norbert Rittel
d9a67f603d Fix grammar in new_automation_setup_failed_text (#27898)
* Fix grammar in `new_automation_setup_failed_text`

* Remove excessive comma
2025-11-10 18:48:26 +01:00
Wendelin
5f37f8c0ab Use generic picker for target-picker (#27850)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 17:15:16 +01:00
Petar Petrov
f222702abf Fix entity name in statistics chart (#27896) 2025-11-10 15:08:33 +01:00
Copilot
2107b7c267 Fix doubled tooltips on timeline charts for mobile devices (#27888)
* Initial plan

* Fix doubled date popups in timeline charts on mobile

Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>

* Add comment explaining triggerTooltip fix

* Actual fix

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 14:31:30 +01:00
Petar Petrov
1c05afebd7 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-10 14:23:26 +01:00
Paul Bottein
7179bb2d26 Assume default visible true for panels (#27894) 2025-11-10 15:04:14 +02:00
Wendelin
95cf1fdcf7 Fix target picker for entity_id: none (#27893)
Fix notFound condition to exclude 'none' in ha-target-picker-item-row
2025-11-10 12:23:16 +00:00
renovate[bot]
9617956cc6 Update vitest monorepo to v4.0.8 (#27892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 14:15:36 +02:00
karwosts
65df464731 Fix entity editor with non-existant entity (#27875) 2025-11-10 14:09:44 +02:00
Wendelin
bd4e9a3d05 Use ha-ripple in ha-md-list-item (#27889) 2025-11-10 14:04:30 +02:00
ildar170975
963fc13a99 relative_time: increase thresholds (#27870)
* increase thresholds

* restored for days & hours
2025-11-10 12:58:13 +02:00
renovate[bot]
ff614918d4 Update vaadinWebComponents monorepo to v24.9.5 (#27884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 12:55:03 +02:00
ildar170975
48aa5fb970 hui-generic-entity-row: add tooltips for relative-time (#27871)
* add tooltips for relative-rime

* lint

* fix import

* prettier

* move a call of uid() to a private property

* some test change
2025-11-10 12:54:26 +02:00
ildar170975
190af65756 Display tooltips for labels (#27613)
* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description for ha-label

* add ha-tooltip for ha-input-chip

* add ha-tooltip

* replace() -> replaceAll()

* replace() -> replaceAll()

* prettier

* fix styles to enlarge an "active tooltip area"

* additional check for null for "description"

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* call uid() in constructor

* fix a check for null

* attempting to bypass insecure randomness

* move a call of uid() into constructor

* uid generation tweak

* Apply suggestions from code review

* prettier

* simplify

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 10:04:31 +00:00
Petar Petrov
76911e0e0d Dynamic total energy for pie chart (#27883) 2025-11-10 10:20:26 +01:00
Petar Petrov
b8ec7c2e72 Fix chart label outline color (#27882) 2025-11-10 08:53:18 +01:00
dependabot[bot]
10e20c2272 Bump softprops/action-gh-release from 2.4.1 to 2.4.2 (#27879) 2025-11-09 22:14:19 -08:00
dependabot[bot]
2ec05aac2f Bump relative-ci/agent-action from 3.1.0 to 3.2.0 (#27880) 2025-11-09 22:12:30 -08:00
renovate[bot]
61fbe5b53c Update dependency marked to v16.4.2 (#27877)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 06:57:39 +01:00
Yuksel Beyti
5f3f1c7139 Fix malformed HTML tags in backup backups component (#27872) 2025-11-09 13:56:43 +02:00
ildar170975
48f37b1b1e "Expand" tooltips: remove a trailing dot (#27869)
remove trailing dot
2025-11-09 09:00:28 +01:00
karwosts
9091df9db5 Fix sequence action copy-paste (#27652) 2025-11-08 15:50:38 +01:00
renovate[bot]
1cd7a1cd78 Update dependency @rsdoctor/rspack-plugin to v1.3.8 (#27867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:48:08 +01:00
renovate[bot]
ae5c7026b9 Update dependency @rspack/core to v1.6.1 (#27864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:16:38 +01:00
renovate[bot]
3900f3995a Update vitest monorepo to v4.0.7 (#27862)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:16:16 +01:00
Paul Bottein
237f974ee8 Only show panel with default visible flag in sidebar (#27838) 2025-11-08 13:15:43 +01:00
Paul Bottein
b2ec4b7d2c Fix backup download and delete actions (#27851) 2025-11-07 13:43:00 +02:00
Aidan Timson
b4c83d7877 Migrate dialog-repairs-issue to ha-wa-dialog (#27667)
* Migrate dialog-repairs-issue to ha-wa-dialog

* Allow for custom slots for header title and subtitle, using boolean to enable

* Fix typing

* Cleanup

* Refactor header slot logic

* [workaround] pass aria attributes to dialog

* Update src/components/ha-wa-dialog.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Remove unused (by wa) aira attrs

* Fix imports

* Revert "Remove unused (by wa) aira attrs"

This reverts commit ce97bebce4.

* Remove workaround

* Remove

* Format

* Fix subtitle margin

* Use spacing token

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-07 13:42:07 +02:00
Paul Bottein
22c53b6859 Add area context in zha, zwave and bluetooth graph (#27849)
* Add area context in zha, zwave and bluetooth graph

* Fix undefined device

* Fix typing

* Add context to find area
2025-11-07 13:34:44 +02:00
Paul Bottein
750b1e5e16 Avoid cropping in base graph (#27848)
* Avoid cropping in base graph

* Add bottom margin
2025-11-07 11:25:35 +01:00
Aidan Timson
5801ce944c Refactor dashboard conditional listeners to use mixin (#27837)
* Update media query to new APIs

* Refactor conditional rendering

* Cleanup

* Restore original functionality

* Restore

* Reduce

* Reduce

* Reduce

* Restore legacy code while we still support them

* Clear conditional listeners before setting up again
2025-11-07 12:22:16 +02:00
Petar Petrov
79ad9dbf44 Disable graph resize animation for general resizing (#27816) 2025-11-07 08:34:42 +01:00
Wendelin
9814533d47 Add service titles to automation action sidebar (#27831)
* Add service titles to automation action sidebar

* Add split limit
2025-11-07 07:27:21 +00:00
renovate[bot]
bdb6c684c0 Update dependency eslint to v9.39.1 (#27846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 08:38:42 +02:00
renovate[bot]
0046167f5c Update dependency typescript-eslint to v8.46.3 (#27842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 08:38:21 +02:00
renovate[bot]
5266f0d761 Update dependency @bundle-stats/plugin-webpack-filter to v4.21.6 (#27841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 08:37:35 +02:00
Jan Bouwhuis
cc5c0d04c4 Fix index for service action translation in service action dialog (#27824) 2025-11-06 15:15:52 +01:00
Wendelin
7f3d5f557f 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-06 15:15:09 +01:00
Wendelin
9b48cd7737 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-06 13:46:53 +00:00
Aidan Timson
11c6f6ec78 Update @home-assistant/webawesome to 3.0.0-beta.6.ha.7 (#27834) 2025-11-06 15:33:10 +02:00
Wendelin
cb0f59b26d Fix floor details area picker (#27827) 2025-11-06 12:42:30 +02:00
Paul Bottein
c89fc35578 Fix OHF logo theme (#27830) 2025-11-06 12:39:52 +02:00
Timothy
f03cd9c239 Add Add entity to feature for external_app (#26346)
* Add Add entity to feature for external_app

* Update icon from plus to plusboxmultiple

* Apply suggestion on the name

* Add missing shouldHandleRequestSelectedEvent that caused duplicate

* WIP

* Rework the logic to match the agreed design

* Rename property

* Apply PR comments

* Apply prettier

* Merge MessageWithAnswer

* Apply PR comments
2025-11-06 08:25:05 +00:00
karwosts
19a4e37933 Fix incorrect unit displayed in energy grid flow settings (#27822) 2025-11-06 08:46:19 +02:00
79 changed files with 2669 additions and 2420 deletions

View File

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

View File

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

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
@@ -89,8 +89,8 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/combo-box": "24.9.5",
"@vaadin/vaadin-themable-mixin": "24.9.5",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -122,7 +122,7 @@
"lit": "3.3.1",
"lit-html": "3.3.1",
"luxon": "3.7.2",
"marked": "16.4.1",
"marked": "16.4.2",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -152,13 +152,13 @@
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.5",
"@bundle-stats/plugin-webpack-filter": "4.21.6",
"@lokalise/node-api": "15.3.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.3.7",
"@rspack/core": "1.6.0",
"@rsdoctor/rspack-plugin": "1.3.8",
"@rspack/core": "1.6.1",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
@@ -178,12 +178,12 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.6",
"@vitest/coverage-v8": "4.0.8",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.38.0",
"eslint": "9.39.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
@@ -217,9 +217,9 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.46.2",
"typescript-eslint": "8.46.3",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.6",
"vitest": "4.0.8",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"

View File

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

View File

@@ -6,7 +6,8 @@ export function downSampleLineData<
data: T[] | undefined,
maxDetails: number,
minX?: number,
maxX?: number
maxX?: number,
useMean = false
): T[] {
if (!data) {
return [];
@@ -17,15 +18,13 @@ export function downSampleLineData<
const min = minX ?? getPointData(data[0]!)[0];
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
const frames = new Map<
number,
{
min: { point: (typeof data)[number]; x: number; y: number };
max: { point: (typeof data)[number]; x: number; y: number };
}
>();
// Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
@@ -36,28 +35,53 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, { min: { point, x, y }, max: { point, x, y } });
frames.set(frameIndex, [{ point, x, y }]);
} else {
if (frame.min.y > y) {
frame.min = { point, x, y };
}
if (frame.max.y < y) {
frame.max = { point, x, y };
}
frame.push({ point, x, y });
}
}
// Convert frames back to points
const result: T[] = [];
for (const [_i, frame] of frames) {
// Use min/max points to preserve visual accuracy
// The order of the data must be preserved so max may be before min
if (frame.min.x > frame.max.x) {
result.push(frame.max.point);
if (useMean) {
// Use mean values for each frame
for (const [_i, framePoints] of frames) {
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
result.push(frame.min.point);
if (frame.min.x < frame.max.x) {
result.push(frame.max.point);
} else {
// Use min/max values for each frame
for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
}
}

View File

@@ -35,7 +35,6 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
const RESIZE_ANIMATION_DURATION = 250;
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
@@ -91,6 +90,8 @@ export class HaChartBase extends LitElement {
private _shouldResizeChart = false;
private _resizeAnimationDuration?: number;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => {
@@ -214,6 +215,7 @@ export class HaChartBase extends LitElement {
) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
}
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
@@ -425,6 +427,7 @@ export class HaChartBase extends LitElement {
...axis.axisPointer?.handle,
show: true,
},
label: { show: false },
},
}
: axis
@@ -625,6 +628,10 @@ export class HaChartBase extends LitElement {
}
private _createTheme(style: CSSStyleDeclaration) {
const textBorderColor =
style.getPropertyValue("--ha-card-background") ||
style.getPropertyValue("--card-background-color");
const textBorderWidth = 2;
return {
color: getAllGraphColors(style),
backgroundColor: "transparent",
@@ -648,22 +655,22 @@ export class HaChartBase extends LitElement {
graph: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
textBorderColor,
textBorderWidth,
},
},
pie: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
textBorderColor,
textBorderWidth,
},
},
sankey: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
textBorderColor,
textBorderWidth,
},
},
categoryAxis: {
@@ -977,11 +984,14 @@ export class HaChartBase extends LitElement {
private _handleChartRenderFinished = () => {
if (this._shouldResizeChart) {
this.chart?.resize({
animation: this._reducedMotion
? undefined
: { duration: RESIZE_ANIMATION_DURATION },
animation:
this._reducedMotion ||
typeof this._resizeAnimationDuration !== "number"
? undefined
: { duration: this._resizeAnimationDuration },
});
this._shouldResizeChart = false;
this._resizeAnimationDuration = undefined;
}
};

View File

@@ -2,7 +2,10 @@ import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
@@ -16,6 +19,7 @@ import { deepEqual } from "../../common/util/deep-equal";
export interface NetworkNode {
id: string;
name?: string;
context?: string;
category?: number;
value?: number;
symbolSize?: number;
@@ -188,6 +192,25 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
label: {
show: showLabels,
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: {
focus: isMobile ? "none" : "adjacency",
@@ -225,6 +248,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
({
id: node.id,
name: node.name,
context: node.context,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,

View File

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

View File

@@ -197,9 +197,6 @@ export class HaDevicePicker extends LitElement {
const placeholder =
this.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);
@@ -209,7 +206,10 @@ export class HaDevicePicker extends LitElement {
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.device-picker.no_devices"
)}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
@@ -233,6 +233,11 @@ export class HaDevicePicker extends LitElement {
this.value = 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 {

View File

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

View File

@@ -271,7 +271,6 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
@@ -279,7 +278,6 @@ export class HaStatisticPicker extends LitElement {
statistic_id: id,
primary,
secondary,
a11y_label: a11yLabel,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
@@ -458,9 +456,6 @@ export class HaStatisticPicker extends LitElement {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.statistic-picker.no_match"
);
return html`
<ha-generic-picker
@@ -468,7 +463,10 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.notFoundLabel=${notFoundLabel}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.statistic-picker.no_statistics"
)}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
@@ -521,6 +519,11 @@ export class HaStatisticPicker extends LitElement {
await this.updateComplete;
await this._picker?.open();
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.statistic-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -87,6 +87,8 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
@@ -367,14 +369,16 @@ export class HaAreaPicker extends LitElement {
.autofocus=${this.autofocus}
.label=${this.label}
.helper=${this.helper}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")}
.disabled=${this.disabled}
.required=${this.required}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -422,6 +426,11 @@ export class HaAreaPicker extends LitElement {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.area-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

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

View File

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

View File

@@ -25,9 +25,6 @@ import "./ha-svg-icon";
export class HaGenericPicker extends LitElement {
@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 required = false;
@@ -49,8 +46,11 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@property({ attribute: false })
public getItems?: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -64,8 +64,11 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string);
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: "popover-placement" })
public popoverPlacement:
@@ -85,6 +88,25 @@ export class HaGenericPicker extends LitElement {
/** If set picker shows an add button instead of textbox when value isn't set */
@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("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -97,6 +119,11 @@ export class HaGenericPicker extends LitElement {
@state() private _openedNarrow = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
private _narrow = false;
// helper to set new value after closing picker, to avoid flicker
@@ -189,16 +216,19 @@ export class HaGenericPicker extends LitElement {
<ha-picker-combo-box
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ??
(this.hass?.localize("ui.common.search") || "Search")}
.label=${this.searchLabel}
.value=${this.value}
@value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel}
.emptyLabel=${this.emptyLabel}
.getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn}
.mode=${dialogMode ? "dialog" : "popover"}
.sections=${this.sections}
.sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection}
></ha-picker-combo-box>
`;
}

View File

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

View File

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

View File

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

View File

@@ -125,9 +125,10 @@ export class HaLanguagePicker extends LitElement {
.hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this.hass?.localize(
"ui.components.language-picker.no_match"
)}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages"
) || "No languages available"}
.placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language")}
@@ -172,6 +173,15 @@ export class HaLanguagePicker extends LitElement {
this.value = ev.detail.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 {

View File

@@ -1,7 +1,8 @@
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
import { css } from "lit";
import { css, html, nothing, type TemplateResult } from "lit";
import { customElement } from "lit/decorators";
import "./ha-ripple";
export const haMdListStyles = [
styles,
@@ -25,6 +26,18 @@ export const haMdListStyles = [
@customElement("ha-md-list-item")
export class HaMdListItem extends ListItemEl {
static override styles = haMdListStyles;
protected renderRipple(): TemplateResult | typeof nothing {
if (this.type === "text") {
return nothing;
}
return html`<ha-ripple
part="ripple"
for="item"
?disabled=${this.disabled && this.type !== "link"}
></ha-ripple>`;
}
}
declare global {

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify } from "@mdi/js";
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -14,11 +14,12 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaFuse } from "../resources/fuse";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
import "./ha-combo-box-item";
import "./ha-icon";
import "./ha-textfield";
@@ -27,28 +28,18 @@ import type { HaTextField } from "./ha-textfield";
export interface PickerComboBoxItem {
id: string;
primary: string;
a11y_label?: string;
secondary?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
icon?: string;
}
// 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 NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item
) => html`
<ha-combo-box-item
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
compact
>
<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
@@ -87,8 +78,11 @@ export class HaPickerComboBox extends LitElement {
@state() private _listScrolled = false;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@property({ attribute: false })
public getItems?: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -96,21 +90,45 @@ export class HaPickerComboBox extends LitElement {
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string);
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@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("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: PickerComboBoxItemWithLabel[] = [];
@state() private _items: (PickerComboBoxItem | string)[] = [];
private _allItems: PickerComboBoxItemWithLabel[] = [];
@state() private _sectionTitle?: string;
private _allItems: (PickerComboBoxItem | string)[] = [];
private _selectedItemIndex = -1;
@@ -121,6 +139,8 @@ export class HaPickerComboBox extends LitElement {
private _removeKeyboardShortcuts?: () => void;
private _search = "";
protected firstUpdated() {
this._registerKeyboardShortcuts();
}
@@ -145,74 +165,142 @@ export class HaPickerComboBox extends LitElement {
"Search"}
@input=${this._filterChanged}
></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
@scroll=${this._onScrollList}
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer> `;
}
private _defaultNotFoundItem = memoizeOne(
(
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",
})
);
private _renderSectionButtons() {
if (!this.sections || this.sections.length === 0) {
return nothing;
}
private _getAdditionalItems = (searchString?: string) => {
const items = this.getAdditionalItems?.(searchString) || [];
return html`
<ha-chip-set class="sections">
${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>
`;
}
return items.map<PickerComboBoxItemWithLabel>((item) => ({
...item,
a11y_label: item.a11y_label || item.primary,
}));
};
@eventOptions({ passive: true })
private _visibilityChanged(ev) {
if (
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 _getItems = (): PickerComboBoxItemWithLabel[] => {
const items = this.getItems ? this.getItems() : [];
private _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || [];
const sortedItems = items
.map<PickerComboBoxItemWithLabel>((item) => ({
...item,
a11y_label: item.a11y_label || item.primary,
}))
.sort((entityA, entityB) =>
private _getItems = () => {
let items = [
...(this.getItems
? this.getItems(this._search, this.selectedSection)
: []),
];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
entityA.sorting_label!,
entityB.sorting_label!,
(entityA as PickerComboBoxItem).sorting_label!,
(entityB as PickerComboBoxItem).sorting_label!,
this.hass?.locale.language ?? navigator.language
)
);
}
if (!sortedItems.length) {
sortedItems.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
);
if (!items.length) {
items.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
sortedItems.push(...additionalItems);
return sortedItems;
items.push(...additionalItems);
if (this.mode === "dialog") {
items.push("padding"); // padding for safe area inset
}
return items;
};
private _renderItem = (item: PickerComboBoxItem, index: number) => {
private _renderItem = (item: PickerComboBoxItem | string, 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;
return html`<div
id=${`list-item-${index}`}
@@ -221,9 +309,7 @@ export class HaPickerComboBox extends LitElement {
.index=${index}
@click=${this._valueSelected}
>
${item.id === NO_MATCHING_ITEMS_FOUND_ID
? DEFAULT_ROW_RENDERER(item, index)
: renderer(item, index)}
${renderer(item, index)}
</div>`;
};
@@ -242,10 +328,6 @@ export class HaPickerComboBox extends LitElement {
const value = (ev.currentTarget as any).value as string;
const newValue = value?.trim();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
fireEvent(this, "value-changed", { value: newValue });
};
@@ -256,51 +338,83 @@ export class HaPickerComboBox extends LitElement {
private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim();
this._search = searchString;
if (!searchString) {
this._items = this._allItems;
return;
}
if (this.sections?.length) {
this._items = this._getItems();
} else {
if (!searchString) {
this._items = this._allItems;
return;
}
const index = this._fuseIndex(this._allItems);
const fuse = new HaFuse(
this._allItems,
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
);
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 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 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[]
);
}
const additionalItems = this._getAdditionalItems(searchString);
items.push(...additionalItems);
filteredItems = items;
this._items = filteredItems as PickerComboBoxItem[];
}
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems,
this._allItems
);
}
this._items = filteredItems as PickerComboBoxItemWithLabel[];
this._selectedItemIndex = -1;
if (this._virtualizerElement) {
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() {
this._removeKeyboardShortcuts = tinykeys(this, {
ArrowUp: this._selectPreviousItem,
@@ -344,7 +458,7 @@ export class HaPickerComboBox extends LitElement {
return;
}
if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
if (typeof items[nextIndex] === "string") {
// Skip titles, padding and empty search
if (nextIndex === maxItems) {
return;
@@ -373,7 +487,7 @@ export class HaPickerComboBox extends LitElement {
return;
}
if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
if (typeof items[nextIndex] === "string") {
// Skip titles, padding and empty search
if (nextIndex === 0) {
return;
@@ -395,13 +509,6 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = 0;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
@@ -419,13 +526,6 @@ export class HaPickerComboBox extends LitElement {
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") {
this._selectedItemIndex = nextIndex - 1;
} else {
@@ -453,10 +553,7 @@ export class HaPickerComboBox extends LitElement {
ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if (
this._virtualizerElement?.items.length === 1 &&
firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
) {
if (this._virtualizerElement?.items.length === 1) {
fireEvent(this, "value-changed", {
value: firstItem.id,
});
@@ -472,7 +569,7 @@ export class HaPickerComboBox extends LitElement {
const item = this._virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
if (item) {
fireEvent(this, "value-changed", { value: item.id });
}
};
@@ -484,6 +581,9 @@ export class HaPickerComboBox extends LitElement {
this._selectedItemIndex = -1;
}
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
static styles = [
haStyleScrollbar,
css`
@@ -558,6 +658,80 @@ export class HaPickerComboBox extends LitElement {
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

@@ -157,7 +157,9 @@ export const computePanels = memoizeOne(
Object.values(panels).forEach((panel) => {
if (
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;
}

View File

@@ -1,15 +1,31 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { mdiPlaylistPlus } from "@mdi/js";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
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 { getEntities, type EntityComboBoxItem } from "../data/entity_registry";
import { domainToName } from "../data/integration";
import { getLabels, type LabelRegistryEntry } from "../data/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
@@ -18,18 +34,23 @@ import {
type TargetTypeFloorless,
} from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../resources/fuse";
import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-input-helper-text";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-svg-icon";
import "./ha-tree-indicator";
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";
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
@customElement("ha-target-picker")
export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -68,23 +89,54 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
@state() private _open = false;
@state() private _selectedSection?: TargetTypeFloorless;
@state() private _addTargetWidth = 0;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _narrow = false;
@state() private _pickerFilter?: TargetTypeFloorless;
@state() private _pickerWrapperOpen = false;
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLDivElement;
@query("ha-target-picker-selector")
private _targetPickerSelectorElement?: HaTargetPickerSelector;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
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() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
@@ -289,137 +341,63 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
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`
<div class="add-target-wrapper">
<ha-button
id="add-target-button"
size="small"
appearance="filled"
@click=${this._showPicker}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.helper=${this.helper}
.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-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}
</ha-generic-picker>
</div>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`;
}
connectedCallback() {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
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) {
private _targetPicked(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
this._open = false;
this._pickerWrapperOpen = false;
if (this._newTarget) {
this._addTarget(this._newTarget.id, this._newTarget.type);
this._newTarget = undefined;
const value = ev.detail.value;
if (value.startsWith(CREATE_ID)) {
this._createNewDomainElement(value.substring(CREATE_ID.length));
return;
}
}
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>
`;
const [type, id] = ev.detail.value.split(SEPARATOR);
this._addTarget(id, type as TargetType);
}
private _addTarget(id: string, type: TargetType) {
@@ -454,26 +432,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
?.removeAttribute("collapsed");
}
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;
private _createNewDomainElement = (domain: string) => {
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
@@ -675,6 +634,465 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
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.entityFilter,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.value,
searchString,
this._configEntryLookup,
this._selectedSection
);
};
private _getItemsMemoized = memoizeOne(
(
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 entities = this._getEntitiesMemoized(
this.hass,
includeDomains,
undefined,
entityFilter,
includeDeviceClasses,
undefined,
undefined,
targetValue?.entity_id
? ensureArray(targetValue.entity_id)
: undefined,
undefined,
`entity${SEPARATOR}`
);
if (searchTerm) {
entities = this._filterGroup(
"entity",
entities,
searchTerm,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
) as EntityComboBoxItem[];
}
if (!filterType && entities.length) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.entities")
);
}
items.push(...entities);
}
if (!filterType || filterType === "device") {
let devices = this._getDevicesMemoized(
this.hass,
configEntryLookup,
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.device_id
? ensureArray(targetValue.device_id)
: undefined,
undefined,
`device${SEPARATOR}`
);
if (searchTerm) {
devices = this._filterGroup("device", devices, searchTerm);
}
if (!filterType && devices.length) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.devices")
);
}
items.push(...devices);
}
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(
this.hass.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(
this.hass.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 {
return css`
.add-target-wrapper {
@@ -683,31 +1101,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
margin-top: var(--ha-space-3);
}
wa-popover {
--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);
ha-generic-picker {
width: 100%;
}
${unsafeCSS(chipStyles)}

View File

@@ -1,6 +1,4 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import {
customElement,
eventOptions,
@@ -8,6 +6,9 @@ import {
query,
state,
} 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 { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
@@ -31,6 +32,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
*
* @slot header - Replace the entire header area.
* @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 - Dialog content body.
* @slot footer - Dialog footer content.
@@ -52,8 +55,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @attr {boolean} open - Controls the dialog open state.
* @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 {string} header-title - Header title text when no custom title slot is provided.
* @attr {string} header-subtitle - Header subtitle text when no custom subtitle slot is provided.
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @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.
*
@@ -72,6 +75,12 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
export class HaWaDialog extends LitElement {
@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 })
public open = false;
@@ -81,11 +90,11 @@ export class HaWaDialog extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false;
@property({ type: String, attribute: "header-title" })
public headerTitle = "";
@property({ attribute: "header-title" })
public headerTitle?: string;
@property({ type: String, attribute: "header-subtitle" })
public headerSubtitle = "";
@property({ attribute: "header-subtitle" })
public headerSubtitle?: string;
@property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below";
@@ -117,6 +126,11 @@ export class HaWaDialog extends LitElement {
.open=${this._open}
.lightDismiss=${!this.preventScrimClose}
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-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
@@ -133,14 +147,14 @@ export class HaWaDialog extends LitElement {
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle
? html`<span slot="title" class="title">
${this.headerTitle !== undefined
? html`<span slot="title" class="title" id="ha-wa-dialog-title">
${this.headerTitle}
</span>`
: nothing}
${this.headerSubtitle
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: nothing}
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>

View File

@@ -545,7 +545,7 @@ export class HaTargetPickerItemRow extends LitElement {
name: entityName || deviceName || item,
context,
stateObject,
notFound: !stateObject,
notFound: !stateObject && item !== "all" && item !== "none",
};
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -53,6 +53,9 @@ export const enum CalendarEntityFeature {
UPDATE_EVENT = 4,
}
/** Type for date values that can come from REST API or subscription */
type CalendarDateValue = string | { dateTime: string } | { date: string };
export const fetchCalendarEvents = async (
hass: HomeAssistant,
start: Date,
@@ -65,11 +68,11 @@ export const fetchCalendarEvents = async (
const calEvents: CalendarEvent[] = [];
const errors: string[] = [];
const promises: Promise<CalendarEvent[]>[] = [];
const promises: Promise<CalendarEventApiData[]>[] = [];
calendars.forEach((cal) => {
promises.push(
hass.callApi<CalendarEvent[]>(
hass.callApi<CalendarEventApiData[]>(
"GET",
`calendars/${cal.entity_id}${params}`
)
@@ -77,7 +80,7 @@ export const fetchCalendarEvents = async (
});
for (const [idx, promise] of promises.entries()) {
let result: CalendarEvent[];
let result: CalendarEventApiData[];
try {
// eslint-disable-next-line no-await-in-loop
result = await promise;
@@ -87,53 +90,16 @@ export const fetchCalendarEvents = async (
}
const cal = calendars[idx];
result.forEach((ev) => {
const eventStart = getCalendarDate(ev.start);
const eventEnd = getCalendarDate(ev.end);
if (!eventStart || !eventEnd) {
return;
const normalized = normalizeSubscriptionEventData(ev, cal);
if (normalized) {
calEvents.push(normalized);
}
const eventData: CalendarEventData = {
uid: ev.uid,
summary: ev.summary,
description: ev.description,
dtstart: eventStart,
dtend: eventEnd,
recurrence_id: ev.recurrence_id,
rrule: ev.rrule,
};
const event: CalendarEvent = {
start: eventStart,
end: eventEnd,
title: ev.summary,
backgroundColor: cal.backgroundColor,
borderColor: cal.backgroundColor,
calendar: cal.entity_id,
eventData: eventData,
};
calEvents.push(event);
});
}
return { events: calEvents, errors };
};
const getCalendarDate = (dateObj: any): string | undefined => {
if (typeof dateObj === "string") {
return dateObj;
}
if (dateObj.dateTime) {
return dateObj.dateTime;
}
if (dateObj.date) {
return dateObj.date;
}
return undefined;
};
export const getCalendars = (hass: HomeAssistant): Calendar[] =>
Object.keys(hass.states)
.filter(
@@ -191,3 +157,89 @@ export const deleteCalendarEvent = (
recurrence_id,
recurrence_range,
});
/**
* Calendar event data from both REST API and WebSocket subscription.
* Both APIs use the same data format.
*/
export interface CalendarEventApiData {
summary: string;
start: CalendarDateValue;
end: CalendarDateValue;
description?: string | null;
location?: string | null;
uid?: string | null;
recurrence_id?: string | null;
rrule?: string | null;
}
export interface CalendarEventSubscription {
events: CalendarEventApiData[] | null;
}
export const subscribeCalendarEvents = (
hass: HomeAssistant,
entity_id: string,
start: Date,
end: Date,
callback: (update: CalendarEventSubscription) => void
) =>
hass.connection.subscribeMessage<CalendarEventSubscription>(callback, {
type: "calendar/event/subscribe",
entity_id,
start: start.toISOString(),
end: end.toISOString(),
});
const getCalendarDate = (dateObj: CalendarDateValue): string | undefined => {
if (typeof dateObj === "string") {
return dateObj;
}
if ("dateTime" in dateObj) {
return dateObj.dateTime;
}
if ("date" in dateObj) {
return dateObj.date;
}
return undefined;
};
/**
* Normalize calendar event data from API format to internal format.
* Handles both REST API format (with dateTime/date objects) and subscription format (strings).
* Converts to internal format with { dtstart, dtend, ... }
*/
export const normalizeSubscriptionEventData = (
eventData: CalendarEventApiData,
calendar: Calendar
): CalendarEvent | null => {
const eventStart = getCalendarDate(eventData.start);
const eventEnd = getCalendarDate(eventData.end);
if (!eventStart || !eventEnd) {
return null;
}
const normalizedEventData: CalendarEventData = {
summary: eventData.summary,
dtstart: eventStart,
dtend: eventEnd,
description: eventData.description ?? undefined,
uid: eventData.uid ?? undefined,
recurrence_id: eventData.recurrence_id ?? undefined,
rrule: eventData.rrule ?? undefined,
};
return {
start: eventStart,
end: eventEnd,
title: eventData.summary,
backgroundColor: calendar.backgroundColor,
borderColor: calendar.backgroundColor,
calendar: calendar.entity_id,
eventData: normalizedEventData,
};
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
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,6 +8,7 @@ import {
mdiPencil,
mdiPencilOff,
mdiPencilOutline,
mdiPlusBoxMultipleOutline,
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -60,6 +61,7 @@ import {
computeShowLogBookComponent,
} from "./const";
import "./controls/more-info-default";
import "./ha-more-info-add-to";
import "./ha-more-info-history-and-logbook";
import "./ha-more-info-info";
import "./ha-more-info-settings";
@@ -73,7 +75,7 @@ export interface MoreInfoDialogParams {
data?: Record<string, any>;
}
type View = "info" | "history" | "settings" | "related";
type View = "info" | "history" | "settings" | "related" | "add_to";
interface ChildView {
viewTag: string;
@@ -194,6 +196,10 @@ export class MoreInfoDialog extends LitElement {
);
}
private _shouldShowAddEntityTo(): boolean {
return !!this.hass.auth.external?.config.hasEntityAddTo;
}
private _getDeviceId(): string | null {
const entity = this.hass.entities[this._entityId!] as
| EntityRegistryEntry
@@ -295,6 +301,11 @@ export class MoreInfoDialog extends LitElement {
this._setView("related");
}
private _goToAddEntityTo(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) return;
this._setView("add_to");
}
private _breadcrumbClick(ev: Event) {
ev.stopPropagation();
this._setView("related");
@@ -521,6 +532,22 @@ export class MoreInfoDialog extends LitElement {
.path=${mdiInformationOutline}
></ha-svg-icon>
</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>
`
: nothing}
@@ -613,7 +640,14 @@ export class MoreInfoDialog extends LitElement {
: "entity"}
></ha-related-items>
`
: nothing
: this._currView === "add_to"
? html`
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
></ha-more-info-add-to>
`
: nothing
)}
</div>
`

View File

@@ -102,6 +102,17 @@ class DialogEditSidebar extends LitElement {
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 = [
...beforeSpacer,
...panels.filter((panel) => this._hidden!.includes(panel.url_path)),

View File

@@ -36,6 +36,13 @@ interface EMOutgoingMessageConfigGet extends EMMessage {
type: "config/get";
}
interface EMOutgoingMessageEntityAddToGetActions extends EMMessage {
type: "entity/add_to/get_actions";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageBarCodeScan extends EMMessage {
type: "bar_code/scan";
payload: {
@@ -75,6 +82,10 @@ interface EMOutgoingMessageWithAnswer {
request: EMOutgoingMessageConfigGet;
response: ExternalConfig;
};
"entity/add_to/get_actions": {
request: EMOutgoingMessageEntityAddToGetActions;
response: ExternalEntityAddToActions;
};
}
interface EMOutgoingMessageExoplayerPlayHLS extends EMMessage {
@@ -157,6 +168,14 @@ 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 =
| EMMessageResultError
| EMMessageResultSuccess
@@ -177,7 +196,8 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageThemeUpdate
| EMOutgoingMessageThreadStoreInPlatformKeychain
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice;
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo;
export interface EMIncomingMessageRestart {
id: number;
@@ -305,6 +325,19 @@ export interface ExternalConfig {
canSetupImprov?: boolean;
downloadFileSupported?: boolean;
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 {

View File

@@ -0,0 +1,104 @@
import type { ReactiveElement } from "lit";
import { listenMediaQuery } from "../common/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";
type Constructor<T> = abstract new (...args: any[]) => T;
/**
* 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;
}, []);
}
/**
* 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);
});
}
/**
* Mixin to handle conditional listeners for visibility control
*
* Provides lifecycle management for listeners (media queries, time-based, state changes, etc.)
* that control conditional visibility of components.
*
* Usage:
* 1. Extend your component with ConditionalListenerMixin(ReactiveElement)
* 2. Override setupConditionalListeners() to setup your listeners
* 3. Use addConditionalListener() to register unsubscribe functions
* 4. Call clearConditionalListeners() and setupConditionalListeners() when config changes
*
* The mixin automatically:
* - Sets up listeners when component connects to DOM
* - Cleans up listeners when component disconnects from DOM
*/
export const ConditionalListenerMixin = <
T extends Constructor<ReactiveElement>,
>(
superClass: T
) => {
abstract class ConditionalListenerClass extends superClass {
private __listeners: (() => void)[] = [];
public connectedCallback() {
super.connectedCallback();
this.setupConditionalListeners();
}
public disconnectedCallback() {
super.disconnectedCallback();
this.clearConditionalListeners();
}
protected clearConditionalListeners(): void {
this.__listeners.forEach((unsub) => unsub());
this.__listeners = [];
}
protected addConditionalListener(unsubscribe: () => void): void {
this.__listeners.push(unsubscribe);
}
protected setupConditionalListeners(): void {
// Override in subclass
}
}
return ConditionalListenerClass;
};

View File

@@ -1,5 +1,6 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { mdiChevronDown, mdiPlus, mdiRefresh } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -20,8 +21,17 @@ import "../../components/ha-menu-button";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-two-pane-top-app-bar-fixed";
import type { Calendar, CalendarEvent } from "../../data/calendar";
import { fetchCalendarEvents, getCalendars } from "../../data/calendar";
import type {
Calendar,
CalendarEvent,
CalendarEventSubscription,
CalendarEventApiData,
} from "../../data/calendar";
import {
getCalendars,
normalizeSubscriptionEventData,
subscribeCalendarEvents,
} from "../../data/calendar";
import { fetchIntegrationManifest } from "../../data/integration";
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
import { haStyle } from "../../resources/styles";
@@ -42,6 +52,8 @@ class PanelCalendar extends LitElement {
@state() private _error?: string = undefined;
@state() private _errorCalendars: string[] = [];
@state()
@storage({
key: "deSelectedCalendars",
@@ -53,6 +65,8 @@ class PanelCalendar extends LitElement {
private _end?: Date;
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
private _showPaneController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 750,
});
@@ -78,6 +92,7 @@ class PanelCalendar extends LitElement {
super.disconnectedCallback();
this._mql?.removeListener(this._setIsMobile!);
this._mql = undefined;
this._unsubscribeAll();
}
private _setIsMobile = (ev: MediaQueryListEvent) => {
@@ -194,19 +209,95 @@ class PanelCalendar extends LitElement {
.map((cal) => cal);
}
private async _fetchEvents(
start: Date | undefined,
end: Date | undefined,
calendars: Calendar[]
): Promise<{ events: CalendarEvent[]; errors: string[] }> {
if (!calendars.length || !start || !end) {
return { events: [], errors: [] };
private _subscribeCalendarEvents(calendars: Calendar[]): void {
if (!this._start || !this._end || calendars.length === 0) {
return;
}
return fetchCalendarEvents(this.hass, start, end, calendars);
this._error = undefined;
calendars.forEach((calendar) => {
// Unsubscribe existing subscription if any
if (calendar.entity_id in this._unsubs) {
this._unsubs[calendar.entity_id]
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
});
}
const unsub = subscribeCalendarEvents(
this.hass,
calendar.entity_id,
this._start!,
this._end!,
(update: CalendarEventSubscription) => {
this._handleCalendarUpdate(calendar, update);
}
);
this._unsubs[calendar.entity_id] = unsub;
});
}
private async _requestSelected(ev: CustomEvent<RequestSelectedDetail>) {
private _handleCalendarUpdate(
calendar: Calendar,
update: CalendarEventSubscription
): void {
// Remove events from this calendar
this._events = this._events.filter(
(event) => event.calendar !== calendar.entity_id
);
if (update.events === null) {
// Error fetching events
if (!this._errorCalendars.includes(calendar.entity_id)) {
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
}
this._handleErrors(this._errorCalendars);
return;
}
// Remove from error list if successfully loaded
this._errorCalendars = this._errorCalendars.filter(
(id) => id !== calendar.entity_id
);
this._handleErrors(this._errorCalendars);
// Add new events from this calendar
const newEvents: CalendarEvent[] = update.events
.map((eventData: CalendarEventApiData) =>
normalizeSubscriptionEventData(eventData, calendar)
)
.filter((event): event is CalendarEvent => event !== null);
this._events = [...this._events, ...newEvents];
}
private async _unsubscribeAll(): Promise<void> {
await Promise.all(
Object.values(this._unsubs).map((unsub) =>
unsub
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
})
)
);
this._unsubs = {};
}
private _unsubscribeCalendar(entityId: string): void {
if (entityId in this._unsubs) {
this._unsubs[entityId]
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
});
delete this._unsubs[entityId];
}
}
private _requestSelected(ev: CustomEvent<RequestSelectedDetail>) {
ev.stopPropagation();
const entityId = (ev.target as HaListItem).value;
if (ev.detail.selected) {
@@ -223,13 +314,10 @@ class PanelCalendar extends LitElement {
if (!calendar) {
return;
}
const result = await this._fetchEvents(this._start, this._end, [
calendar,
]);
this._events = [...this._events, ...result.events];
this._handleErrors(result.errors);
this._subscribeCalendarEvents([calendar]);
} else {
this._deSelectedCalendars = [...this._deSelectedCalendars, entityId];
this._unsubscribeCalendar(entityId);
this._events = this._events.filter(
(event) => event.calendar !== entityId
);
@@ -254,23 +342,15 @@ class PanelCalendar extends LitElement {
): Promise<void> {
this._start = ev.detail.start;
this._end = ev.detail.end;
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
await this._unsubscribeAll();
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
}
private async _handleRefresh(): Promise<void> {
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
await this._unsubscribeAll();
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
}
private _handleErrors(error_entity_ids: string[]) {

View File

@@ -104,7 +104,6 @@ export class HaConfigApplicationCredentials extends LitElement {
),
sortable: true,
filterable: true,
direction: "asc",
},
actions: {
title: "",

View File

@@ -8,24 +8,24 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-aliases-editor";
import "../../../components/ha-area-picker";
import "../../../components/ha-button";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-picker";
import "../../../components/ha-picture-upload";
import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import "../../../components/ha-area-picker";
import { updateAreaRegistryEntry } from "../../../data/area_registry";
import type {
FloorRegistryEntry,
FloorRegistryEntryMutableParams,
} from "../../../data/floor_registry";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { FloorRegistryDetailDialogParams } from "./show-dialog-floor-registry-detail";
import { showAreaRegistryDetailDialog } from "./show-dialog-area-registry-detail";
import { updateAreaRegistryEntry } from "../../../data/area_registry";
import type { FloorRegistryDetailDialogParams } from "./show-dialog-floor-registry-detail";
class DialogFloorDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -168,11 +168,6 @@ class DialogFloorDetail extends LitElement {
)}
</h3>
<p class="description">
${this.hass.localize(
"ui.panel.config.floors.editor.areas_description"
)}
</p>
${areas.length
? html`<ha-chip-set>
${repeat(
@@ -197,13 +192,17 @@ class DialogFloorDetail extends LitElement {
</ha-input-chip>`
)}
</ha-chip-set>`
: nothing}
: html`<p class="description">
${this.hass.localize(
"ui.panel.config.floors.editor.areas_description"
)}
</p>`}
<ha-area-picker
no-add
.hass=${this.hass}
@value-changed=${this._addArea}
.excludeAreas=${areas.map((a) => a.area_id)}
.label=${this.hass.localize(
.addButtonLabel=${this.hass.localize(
"ui.panel.config.floors.editor.add_area"
)}
></ha-area-picker>

View File

@@ -588,7 +588,11 @@ export default class HaAutomationActionRow extends LitElement {
...this._clipboard,
action: deepClone(this.action),
};
copyToClipboard(dump(this.action));
let action = this.action;
if ("sequence" in action) {
action = { ...this.action, metadata: {} };
}
copyToClipboard(dump(action));
}
private _onDisable = () => {

View File

@@ -1054,6 +1054,7 @@ class DialogAddAutomationElement
private _onSearchFocus(ev) {
this._removeKeyboardShortcuts = tinykeys(ev.target, {
ArrowDown: this._focusSearchList,
Enter: this._pickSingleItem,
});
}
@@ -1070,6 +1071,39 @@ class DialogAddAutomationElement
this._itemsListFirstElement.focus();
};
private _pickSingleItem = (ev: KeyboardEvent) => {
if (!this._filter) {
return;
}
ev.preventDefault();
const automationElementType = this._params!.type;
const items = [
...this._getFilteredItems(
automationElementType,
this._filter,
this.hass.localize,
this.hass.services,
this._manifests
),
...(automationElementType !== "trigger"
? this._getFilteredBuildingBlocks(
automationElementType,
this._filter,
this.hass.localize
)
: []),
];
if (items.length !== 1) {
return;
}
this._params!.add(items[0].key);
this.closeDialog();
};
static get styles(): CSSResultGroup {
return [
css`

View File

@@ -452,7 +452,10 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label style=${color ? `--color: ${color}` : ""}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}

View File

@@ -20,7 +20,8 @@ import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
import type { ActionSidebarConfig } from "../../../../data/automation";
import type { RepeatAction } from "../../../../data/script";
import { domainToName } from "../../../../data/integration";
import type { RepeatAction, ServiceAction } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
@@ -83,11 +84,23 @@ export default class HaAutomationSidebarAction extends LitElement {
"ui.panel.config.automation.editor.actions.action"
);
const title =
let title =
this.hass.localize(
`ui.panel.config.automation.editor.actions.type.${type}.label` as LocalizeKeys
) || type;
if (type === "service" && (actionConfig as ServiceAction).action) {
const [domain, service] = (actionConfig as ServiceAction).action!.split(
".",
2
);
title = `${domainToName(this.hass.localize, domain)}: ${
this.hass.localize(`component.${domain}.services.${service}.name`) ||
this.hass.services[domain][service]?.name ||
title
}`;
}
const description = isBuildingBlock
? this.hass.localize(
`ui.panel.config.automation.editor.actions.type.${type}.description.picker` as LocalizeKeys

View File

@@ -372,16 +372,14 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
clickable
id="backup_id"
has-filters
.filters=${
Object.values(this._filters).filter((filter) =>
Array.isArray(filter)
? filter.length
: filter &&
Object.values(filter).some((val) =>
Array.isArray(val) ? val.length : val
)
).length
}
.filters=${Object.values(this._filters).filter((filter) =>
Array.isArray(filter)
? filter.length
: filter &&
Object.values(filter).some((val) =>
Array.isArray(val) ? val.length : val
)
).length}
selectable
.selected=${this._selected.length}
.initialGroupColumn=${this._activeGrouping}
@@ -423,30 +421,28 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
</div>
<div slot="selection-bar">
${
!this.narrow
? html`
<ha-button
appearance="plain"
@click=${this._deleteSelected}
variant="danger"
>
${this.hass.localize(
"ui.panel.config.backup.backups.delete_selected"
)}
</ha-button>
`
: html`
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.backup.backups.delete_selected"
)}
.path=${mdiDelete}
class="warning"
@click=${this._deleteSelected}
></ha-icon-button>
`
}
${!this.narrow
? html`
<ha-button
appearance="plain"
@click=${this._deleteSelected}
variant="danger"
>
${this.hass.localize(
"ui.panel.config.backup.backups.delete_selected"
)}
</ha-button>
`
: html`
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.backup.backups.delete_selected"
)}
.path=${mdiDelete}
class="warning"
@click=${this._deleteSelected}
></ha-icon-button>
`}
</div>
<ha-filter-states
@@ -459,43 +455,39 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
expanded
.narrow=${this.narrow}
></ha-filter-states>
${
!this._needsOnboarding
? html`
<ha-fab
slot="fab"
?disabled=${backupInProgress}
.label=${this.hass.localize(
"ui.panel.config.backup.backups.new_backup"
)}
extended
@click=${this._newBackup}
>
${backupInProgress
? html`<div slot="icon" class="loading">
<ha-spinner .size=${"small"}></ha-spinner>
</div>`
: html`<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>`}
</ha-fab>
`
: nothing
}
${!this._needsOnboarding
? html`
<ha-fab
slot="fab"
?disabled=${backupInProgress}
.label=${this.hass.localize(
"ui.panel.config.backup.backups.new_backup"
)}
extended
@click=${this._newBackup}
>
${backupInProgress
? html`<div slot="icon" class="loading">
<ha-spinner .size=${"small"}></ha-spinner>
</div>`
: html`<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>`}
</ha-fab>
`
: nothing}
</hass-tabs-subpage-data-table>
<ha-md-menu id="overflow-menu" positioning="fixed">
<ha-md-menu-item .clickAction=${this._downloadBackup}>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize("ui.common.download")}
</ha-md-menu-item>
<ha-md-menu-item class="warning" .clickAction=${this._deleteBackup}>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-md-menu-item>
</ha-md-menu>
>
</ha-icon-overflow-menu>
<ha-md-menu-item .clickAction=${this._downloadBackup}>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize("ui.common.download")}
</ha-md-menu-item>
<ha-md-menu-item class="warning" .clickAction=${this._deleteBackup}>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-md-menu-item>
</ha-md-menu>
`;
}
@@ -569,15 +561,15 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
navigate(`/config/backup/details/${id}`);
}
private async _downloadBackup(ev): Promise<void> {
private _downloadBackup = async (ev): Promise<void> => {
const backup = ev.parentElement.anchorElement.backup;
if (!backup) {
return;
}
downloadBackup(this.hass, this, backup, this.config);
}
};
private async _deleteBackup(ev): Promise<void> {
private _deleteBackup = async (ev): Promise<void> => {
const backup = ev.parentElement.anchorElement.backup;
if (!backup) {
return;
@@ -609,7 +601,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
return;
}
fireEvent(this, "ha-refresh-backup-info");
}
};
private async _deleteSelected() {
const confirm = await showConfirmationDialog(this, {

View File

@@ -182,13 +182,11 @@ class HaBlueprintOverview extends LitElement {
sortable: true,
filterable: true,
groupable: true,
direction: "asc",
},
path: {
title: localize("ui.panel.config.blueprint.overview.headers.file_name"),
sortable: true,
filterable: true,
direction: "asc",
flex: 2,
},
fullpath: {

View File

@@ -1,4 +1,4 @@
import { mdiTag, mdiPlus } from "@mdi/js";
import { mdiPlus, mdiTag } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
@@ -194,8 +194,9 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.category-picker.no_match"
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.category-picker.no_categories"
)}
.placeholder=${placeholder}
.value=${this.value}
@@ -254,6 +255,11 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
fireEvent(this, "change");
}, 0);
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.category-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
declare global {

View File

@@ -771,7 +771,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label style=${color ? `--color: ${color}` : ""}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}

View File

@@ -9,6 +9,7 @@ import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-markdown";
import type { HaRadio } from "../../../../components/ha-radio";
import type {
FlowFromGridSourceEnergyPreference,
@@ -19,11 +20,7 @@ import {
emptyFlowToGridSourceEnergyPreference,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getDisplayUnit,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
import { isExternalStatistic } from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
@@ -47,8 +44,6 @@ export class DialogEnergyGridFlowSettings
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _pickedDisplayUnit?: string | null;
@state() private _energy_units?: string[];
@state() private _error?: string;
@@ -81,11 +76,6 @@ export class DialogEnergyGridFlowSettings
: "stat_energy_to"
];
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
initialSourceId,
params.metadata
);
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
@@ -103,7 +93,6 @@ export class DialogEnergyGridFlowSettings
public closeDialog() {
this._params = undefined;
this._source = undefined;
this._pickedDisplayUnit = undefined;
this._error = undefined;
this._excludeList = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -117,10 +106,6 @@ export class DialogEnergyGridFlowSettings
const pickableUnit = this._energy_units?.join(", ") || "";
const unitPriceSensor = this._pickedDisplayUnit
? `${this.hass.config.currency}/${this._pickedDisplayUnit}`
: undefined;
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
const externalSource =
@@ -246,9 +231,15 @@ export class DialogEnergyGridFlowSettings
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${`${this.hass.localize(
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_entity_input`
)} ${unitPriceSensor ? ` (${unitPriceSensor})` : ""}`}
)}
.helper=${html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.grid.flow_dialog.cost_entity_helper",
{ currency: this.hass.config.currency }
)}
></ha-markdown>`}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
@@ -341,16 +332,6 @@ export class DialogEnergyGridFlowSettings
}
private async _statisticChanged(ev: CustomEvent<{ value: string }>) {
if (ev.detail.value) {
const metadata = await getStatisticMetadata(this.hass, [ev.detail.value]);
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
ev.detail.value,
metadata[0]
);
} else {
this._pickedDisplayUnit = undefined;
}
this._source = {
...this._source!,
[this._params!.direction === "from"

View File

@@ -792,7 +792,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label style=${color ? `--color: ${color}` : ""}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}

View File

@@ -634,7 +634,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label style=${color ? `--color: ${color}` : ""}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}

View File

@@ -107,6 +107,8 @@ class HaConfigInfo extends LitElement {
const customUiList: { name: string; url: string; version: string }[] =
(window as any).CUSTOM_UI_LIST || [];
const isDark = this.hass.themes?.darkMode || false;
return html`
<hass-subpage
.hass=${this.hass}
@@ -186,7 +188,7 @@ class HaConfigInfo extends LitElement {
: nothing}
</ul>
</ha-card>
<ha-card outlined class="ohf">
<ha-card outlined class="ohf ${isDark ? "dark" : ""}">
<div>
${this.hass.localize("ui.panel.config.info.proud_part_of")}
</div>
@@ -346,6 +348,10 @@ class HaConfigInfo extends LitElement {
max-width: 250px;
}
.ohf.dark img {
color-scheme: dark;
}
.versions {
display: flex;
flex-direction: column;

View File

@@ -8,6 +8,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import { throttle } from "../../../../../common/util/throttle";
import "../../../../../components/chart/ha-network-graph";
@@ -194,11 +195,17 @@ export class BluetoothNetworkVisualization extends LitElement {
];
const links: NetworkLink[] = [];
Object.values(scanners).forEach((scanner) => {
const scannerDevice = this._sourceDevices[scanner.source];
const scannerDevice = this._sourceDevices[scanner.source] as
| DeviceRegistryEntry
| undefined;
const area = scannerDevice
? getDeviceContext(scannerDevice, this.hass).area
: undefined;
nodes.push({
id: scanner.source,
name:
scannerDevice?.name_by_user || scannerDevice?.name || scanner.name,
context: area?.name,
category: 1,
value: 5,
symbol: "circle",
@@ -231,10 +238,16 @@ export class BluetoothNetworkVisualization extends LitElement {
});
return;
}
const device = this._sourceDevices[node.address];
const device = this._sourceDevices[node.address] as
| DeviceRegistryEntry
| undefined;
const area = device
? getDeviceContext(device, this.hass).area
: undefined;
nodes.push({
id: node.address,
name: this._getBluetoothDeviceName(node.address),
context: area?.name,
value: device ? 1 : 0,
category: device ? 2 : 3,
symbolSize: 20,
@@ -294,15 +307,20 @@ export class BluetoothNetworkVisualization extends LitElement {
const btDevice = this._data.find((d) => d.address === address);
if (btDevice) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}<br><b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${btDevice.rssi}<br><b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b> ${btDevice.source}<br><b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b> ${relativeTime(new Date(btDevice.time * 1000), this.hass.locale)}`;
const device = this._sourceDevices[address];
if (device) {
const area = getDeviceContext(device, this.hass).area;
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
} else {
const device = this._sourceDevices[address];
if (device) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}`;
if (device.area_id) {
const area = this.hass.areas[device.area_id];
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
const area = getDeviceContext(device, this.hass).area;
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
}

View File

@@ -19,6 +19,8 @@ import "../../../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import { formatAsPaddedHex } from "./functions";
import { zhaTabs } from "./zha-config-dashboard";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
@customElement("zha-network-visualization-page")
export class ZHANetworkVisualizationPage extends LitElement {
@@ -117,8 +119,11 @@ export class ZHANetworkVisualizationPage extends LitElement {
} else {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_not_in_db")}</b>`;
}
if (device.area_id) {
const area = this.hass.areas[device.area_id];
const haDevice = this.hass.devices[device.device_reg_id] as
| DeviceRegistryEntry
| undefined;
if (haDevice) {
const area = getDeviceContext(haDevice, this.hass).area;
if (area) {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b>${area.name}`;
}
@@ -204,10 +209,17 @@ export class ZHANetworkVisualizationPage extends LitElement {
category = 2; // End Device
}
const haDevice = this.hass.devices[device.device_reg_id] as
| DeviceRegistryEntry
| undefined;
const area = haDevice
? getDeviceContext(haDevice, this.hass).area
: undefined;
// Create node
nodes.push({
id: device.ieee,
name: device.user_given_name || device.name || device.ieee,
context: area?.name,
category,
value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1,
symbolSize: isCoordinator

View File

@@ -5,6 +5,7 @@ import type {
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import { debounce } from "../../../../../common/util/debounce";
import "../../../../../components/chart/ha-network-graph";
@@ -124,7 +125,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
return tip;
}
const { id, name } = data as any;
const device = this._devices[id];
const device = this._devices[id] as DeviceRegistryEntry | undefined;
const nodeStatus = this._nodeStatuses[id];
let tip = `${(params as any).marker} ${name}`;
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.node_id")}:</b> ${id}`;
@@ -138,6 +139,12 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
tip += `<br><b>Z-Wave Plus:</b> ${this.hass.localize("ui.panel.config.zwave_js.visualization.version")} ${nodeStatus.zwave_plus_version}`;
}
}
if (device) {
const area = getDeviceContext(device, this.hass).area;
if (area) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.area")}:</b> ${area.name}`;
}
}
return tip;
};
@@ -197,10 +204,16 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
if (node.is_controller_node) {
controllerNode = node.node_id;
}
const device = this._devices[node.node_id];
const device = this._devices[node.node_id] as
| DeviceRegistryEntry
| undefined;
const area = device
? getDeviceContext(device, this.hass).area
: undefined;
nodes.push({
id: String(node.node_id),
name: device?.name_by_user ?? device?.name ?? String(node.node_id),
context: area?.name,
value: node.is_controller_node ? 3 : node.is_routing ? 2 : 1,
category:
node.status === NodeStatus.Dead

View File

@@ -320,9 +320,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
if (this.hass.panels.light) {
result.push({
icon: "mdi:lamps",
icon: this.hass.panels.light.icon || "mdi:lamps",
title: this.hass.localize("panel.light"),
show_in_sidebar: false,
show_in_sidebar: true,
mode: "storage",
url_path: "light",
filename: "",
@@ -334,9 +334,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
if (this.hass.panels.security) {
result.push({
icon: "mdi:security",
icon: this.hass.panels.security.icon || "mdi:security",
title: this.hass.localize("panel.security"),
show_in_sidebar: false,
show_in_sidebar: true,
mode: "storage",
url_path: "security",
filename: "",
@@ -348,9 +348,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
if (this.hass.panels.climate) {
result.push({
icon: "mdi:home-thermometer",
icon: this.hass.panels.climate.icon || "mdi:home-thermometer",
title: this.hass.localize("panel.climate"),
show_in_sidebar: false,
show_in_sidebar: true,
mode: "storage",
url_path: "climate",
filename: "",

View File

@@ -41,7 +41,6 @@ class DialogRepairsIssueSubtitle extends LitElement {
:host {
display: block;
font-size: var(--ha-font-size-m);
margin-bottom: 8px;
color: var(--secondary-text-color);
text-overflow: ellipsis;
overflow: hidden;

View File

@@ -1,15 +1,14 @@
import { mdiClose, mdiOpenInNew } from "@mdi/js";
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import "../../../components/ha-alert";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-dialog-header";
import "../../../components/ha-dialog-footer";
import "./dialog-repairs-issue-subtitle";
import "../../../components/ha-markdown";
import type { RepairsIssue } from "../../../data/repairs";
@@ -26,11 +25,12 @@ class DialogRepairsIssue extends LitElement {
@state() private _params?: RepairsIssueDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _open = false;
public showDialog(params: RepairsIssueDialogParams): void {
this._params = params;
this._issue = this._params.issue;
this._open = true;
}
private _dialogClosed() {
@@ -44,7 +44,7 @@ class DialogRepairsIssue extends LitElement {
}
public closeDialog() {
this._dialog?.close();
this._open = false;
}
protected render() {
@@ -62,32 +62,19 @@ class DialogRepairsIssue extends LitElement {
) || this.hass!.localize("ui.panel.config.repairs.dialog.title");
return html`
<ha-md-dialog
open
@closed=${this._dialogClosed}
aria-labelledby="dialog-repairs-issue-title"
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${dialogTitle}
aria-describedby="dialog-repairs-issue-description"
@closed=${this._dialogClosed}
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span
slot="title"
id="dialog-repairs-issue-title"
.title=${dialogTitle}
>${dialogTitle}</span
>
<dialog-repairs-issue-subtitle
slot="subtitle"
.hass=${this.hass}
.issue=${this._issue}
></dialog-repairs-issue-subtitle>
</ha-dialog-header>
<div slot="content" class="dialog-content">
<dialog-repairs-issue-subtitle
slot="headerSubtitle"
.hass=${this.hass}
.issue=${this._issue}
></dialog-repairs-issue-subtitle>
<div class="dialog-content">
${this._issue.breaks_in_ha_version
? html`
<ha-alert alert-type="warning">
@@ -122,8 +109,12 @@ class DialogRepairsIssue extends LitElement {
`
: ""}
</div>
<div slot="actions">
<ha-button appearance="plain" @click=${this._ignoreIssue}>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._ignoreIssue}
>
${this._issue!.ignored
? this.hass!.localize("ui.panel.config.repairs.dialog.unignore")
: this.hass!.localize("ui.panel.config.repairs.dialog.ignore")}
@@ -131,6 +122,7 @@ class DialogRepairsIssue extends LitElement {
${this._issue.learn_more_url
? html`
<ha-button
slot="primaryAction"
appearance="filled"
rel="noopener noreferrer"
href=${learnMoreUrlIsHomeAssistant
@@ -149,8 +141,8 @@ class DialogRepairsIssue extends LitElement {
</ha-button>
`
: ""}
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -172,7 +164,7 @@ class DialogRepairsIssue extends LitElement {
padding-top: 0;
}
ha-alert {
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
display: block;
}
.dismissed {

View File

@@ -473,7 +473,10 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label style=${color ? `--color: ${color}` : ""}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}

View File

@@ -458,7 +458,10 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
.checked=${selected}
.indeterminate=${partial}
></ha-checkbox>
<ha-label style=${color ? `--color: ${color}` : ""}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}

View File

@@ -371,7 +371,11 @@ export class HaManualScriptEditor extends LitElement {
}
}
if (!["sequence", "unknown"].includes(getActionType(config))) {
const actionType = getActionType(config);
if (
!["sequence", "unknown"].includes(actionType) ||
(actionType === "sequence" && "metadata" in config)
) {
config = { sequence: [config] };
}

View File

@@ -90,7 +90,6 @@ export class HaConfigUsers extends LitElement {
title: localize("ui.panel.config.users.picker.headers.username"),
sortable: true,
filterable: true,
direction: "asc",
template: (user) => html`${user.username || "—"}`,
},
group: {
@@ -98,7 +97,6 @@ export class HaConfigUsers extends LitElement {
sortable: true,
filterable: true,
groupable: true,
direction: "asc",
},
is_active: {
title: this.hass.localize(

View File

@@ -2,14 +2,14 @@ import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { HomeAssistant } from "../../../types";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { checkConditionsMet } from "../common/validate-condition";
import { createBadgeElement } from "../create-element/create-badge-element";
import { createErrorBadgeConfig } from "../create-element/create-element-base";
import type { LovelaceBadge } from "../types";
@@ -22,7 +22,7 @@ declare global {
}
@customElement("hui-badge")
export class HuiBadge extends ReactiveElement {
export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceBadgeConfig;
@@ -40,20 +40,16 @@ export class HuiBadge extends ReactiveElement {
private _element?: LovelaceBadge;
private _listeners: MediaQueriesListener[] = [];
protected createRenderRoot() {
return this;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
@@ -137,26 +133,17 @@ export class HuiBadge extends ReactiveElement {
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this.config?.visibility) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
const conditions = this.config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
this.config.visibility,
(matches) => {
this._updateVisibility(hasOnlyMediaQuery && matches);
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}

View File

@@ -8,6 +8,7 @@ import memoizeOne from "memoize-one";
import type { BarSeriesOption, PieSeriesOption } from "echarts/charts";
import { PieChart } from "echarts/charts";
import type { ECElementEvent } from "echarts/types/dist/shared";
import type { PieDataItemOption } from "echarts/types/src/chart/pie/PieSeries";
import { filterXSS } from "../../../../common/util/xss";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { formatNumber } from "../../../../common/number/format_number";
@@ -387,6 +388,7 @@ export class HuiEnergyDevicesGraphCard
});
if (this._chartType === "pie") {
const pieChartData = chartData as NonNullable<PieSeriesOption["data"]>;
const { summedData, compareSummedData } = getSummedData(energyData);
const { consumption, compareConsumption } = computeConsumptionData(
summedData,
@@ -399,7 +401,10 @@ export class HuiEnergyDevicesGraphCard
"from_battery" in summedData;
const untracked = showUntracked
? totalUsed -
chartData.reduce((acc: number, d: any) => acc + d.value[0], 0)
pieChartData.reduce(
(acc: number, d) => acc + (d as PieDataItemOption).value![0],
0
)
: 0;
if (untracked > 0) {
const color = getEnergyColor(
@@ -409,7 +414,7 @@ export class HuiEnergyDevicesGraphCard
false,
"--history-unknown-color"
);
chartData.push({
pieChartData.push({
id: "untracked",
value: [untracked, "untracked"] as any,
name: this.hass.localize(
@@ -442,13 +447,20 @@ export class HuiEnergyDevicesGraphCard
}
}
}
const totalChart = pieChartData.reduce(
(acc: number, d) =>
this._hiddenStats.includes((d as PieDataItemOption).id as string)
? acc
: acc + (d as PieDataItemOption).value![0],
0
);
datasets.push({
type: "pie",
radius: ["0%", compareData ? "30%" : "40%"],
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_graph.total_energy_usage"
),
data: [totalUsed],
data: [totalChart],
label: {
show: true,
position: "center",
@@ -456,7 +468,7 @@ export class HuiEnergyDevicesGraphCard
fontSize: computedStyle.getPropertyValue("--ha-font-size-l"),
lineHeight: 24,
fontWeight: "bold",
formatter: `{a}\n${formatNumber(totalUsed, this.hass.locale)} kWh`,
formatter: `{a}\n${formatNumber(totalChart, this.hass.locale)} kWh`,
},
cursor: "default",
itemStyle: {

View File

@@ -1,3 +1,4 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { classMap } from "lit/directives/class-map";
@@ -7,8 +8,16 @@ import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_elemen
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-card";
import type { Calendar, CalendarEvent } from "../../../data/calendar";
import { fetchCalendarEvents } from "../../../data/calendar";
import type {
Calendar,
CalendarEvent,
CalendarEventSubscription,
CalendarEventApiData,
} from "../../../data/calendar";
import {
normalizeSubscriptionEventData,
subscribeCalendarEvents,
} from "../../../data/calendar";
import type {
CalendarViewChanged,
FullCalendarView,
@@ -65,12 +74,16 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
@state() private _error?: string = undefined;
@state() private _errorCalendars: string[] = [];
private _startDate?: Date;
private _endDate?: Date;
private _resizeObserver?: ResizeObserver;
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
public setConfig(config: CalendarCardConfig): void {
if (!config.entities?.length) {
throw new Error("Entities must be specified");
@@ -86,7 +99,8 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
}));
if (this._config?.entities !== config.entities) {
this._fetchCalendarEvents();
this._unsubscribeAll();
// Subscription will happen when view-changed event fires
}
this._config = { initial_view: "dayGridMonth", ...config };
@@ -115,6 +129,7 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
this._unsubscribeAll();
}
protected render() {
@@ -170,31 +185,85 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
}
}
private _handleViewChanged(ev: HASSDomEvent<CalendarViewChanged>): void {
private async _handleViewChanged(
ev: HASSDomEvent<CalendarViewChanged>
): Promise<void> {
this._startDate = ev.detail.start;
this._endDate = ev.detail.end;
this._fetchCalendarEvents();
await this._unsubscribeAll();
this._subscribeCalendarEvents();
}
private async _fetchCalendarEvents(): Promise<void> {
if (!this._startDate || !this._endDate) {
private _subscribeCalendarEvents(): void {
if (!this.hass || !this._startDate || !this._endDate) {
return;
}
this._error = undefined;
const result = await fetchCalendarEvents(
this.hass!,
this._startDate,
this._endDate,
this._calendars
);
this._events = result.events;
if (result.errors.length > 0) {
this._calendars.forEach((calendar) => {
const unsub = subscribeCalendarEvents(
this.hass!,
calendar.entity_id,
this._startDate!,
this._endDate!,
(update: CalendarEventSubscription) => {
this._handleCalendarUpdate(calendar, update);
}
);
this._unsubs[calendar.entity_id] = unsub;
});
}
private _handleCalendarUpdate(
calendar: Calendar,
update: CalendarEventSubscription
): void {
// Remove events from this calendar
this._events = this._events.filter(
(event) => event.calendar !== calendar.entity_id
);
if (update.events === null) {
// Error fetching events
if (!this._errorCalendars.includes(calendar.entity_id)) {
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
}
this._error = `${this.hass!.localize(
"ui.components.calendar.event_retrieval_error"
)}`;
return;
}
// Remove from error list if successfully loaded
this._errorCalendars = this._errorCalendars.filter(
(id) => id !== calendar.entity_id
);
if (this._errorCalendars.length === 0) {
this._error = undefined;
}
// Add new events from this calendar
const newEvents: CalendarEvent[] = update.events
.map((eventData: CalendarEventApiData) =>
normalizeSubscriptionEventData(eventData, calendar)
)
.filter((event): event is CalendarEvent => event !== null);
this._events = [...this._events, ...newEvents];
}
private async _unsubscribeAll(): Promise<void> {
await Promise.all(
Object.values(this._unsubs).map((unsub) =>
unsub
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
})
)
);
this._unsubs = {};
}
private _measureCard() {

View File

@@ -2,16 +2,16 @@ import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../types";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
import { computeCardSize } from "../common/compute-card-size";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
import { checkConditionsMet } from "../common/validate-condition";
import { tryCreateCardElement } from "../create-element/create-card-element";
import { createErrorCardElement } from "../create-element/create-element-base";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
@@ -24,7 +24,7 @@ declare global {
}
@customElement("hui-card")
export class HuiCard extends ReactiveElement {
export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceCardConfig;
@@ -44,20 +44,16 @@ export class HuiCard extends ReactiveElement {
private _element?: LovelaceCard;
private _listeners: MediaQueriesListener[] = [];
protected createRenderRoot() {
return this;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
@@ -251,26 +247,17 @@ export class HuiCard extends ReactiveElement {
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this.config?.visibility) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
const conditions = this.config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
this.config.visibility,
(matches) => {
this._updateVisibility(hasOnlyMediaQuery && matches);
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}

View File

@@ -175,9 +175,9 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
this._names = {};
this._entities.forEach((config) => {
const stateObj = this.hass!.states[config.entity];
this._names[config.entity] = stateObj
? computeLovelaceEntityName(this.hass!, stateObj, config.name)
: config.entity;
this._names[config.entity] =
computeLovelaceEntityName(this.hass!, stateObj, config.name) ||
config.entity;
});
}

View File

@@ -20,10 +20,11 @@ const calcPoints = (
}
});
const rangeY = maxY - minY || minY * 0.1;
// add top and bottom margins to prevent cropping
maxY += rangeY * 0.1;
minY -= rangeY * 0.1;
if (maxY < 0) {
// all values are negative
// add margin
maxY += rangeY * 0.1;
maxY = Math.min(0, maxY);
yAxisOrigin = 0;
} else if (minY < 0) {
@@ -31,8 +32,6 @@ const calcPoints = (
yAxisOrigin = (maxY / (maxY - minY || 1)) * height;
} else {
// all values are positive
// add margin
minY -= rangeY * 0.1;
minY = Math.max(0, minY);
}
const yDenom = maxY - minY || 1;
@@ -51,7 +50,8 @@ export const coordinates = (
width: number,
height: number,
maxDetails: number,
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number },
useMean = false
) => {
history = history.filter((item) => !Number.isNaN(item[1]));
@@ -59,7 +59,8 @@ export const coordinates = (
history,
maxDetails,
limits?.minX,
limits?.maxX
limits?.maxX,
useMean
);
return calcPoints(sampledData, width, height, limits);
};
@@ -69,7 +70,8 @@ export const coordinatesMinimalResponseCompressedState = (
width: number,
height: number,
maxDetails: number,
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number },
useMean = false
) => {
if (!history?.length) {
return { points: [], yAxisOrigin: 0 };
@@ -81,5 +83,5 @@ export const coordinatesMinimalResponseCompressedState = (
item.lu * 1000,
Number(item.s),
]);
return coordinates(mappedHistory, width, height, maxDetails, limits);
return coordinates(mappedHistory, width, height, maxDetails, limits, useMean);
};

View File

@@ -67,7 +67,7 @@ export const handleAction = async (
await hass.loadBackendTranslation("title");
const localize = await hass.loadBackendTranslation("services");
serviceName = `${domainToName(localize, domain)}: ${
localize(`component.${domain}.services.${serviceName}.name`) ||
localize(`component.${domain}.services.${service}.name`) ||
serviceDomains[domain][service].name ||
service
}`;

View File

@@ -1,6 +1,4 @@
import { ensureArray } from "../../../common/array/ensure-array";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import { listenMediaQuery } from "../../../common/dom/media_query";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { UNKNOWN } from "../../../data/entity";
@@ -362,31 +360,3 @@ export function addEntityToCondition(
}
return condition;
}
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;
}, []);
}
export function attachConditionMediaQueriesListeners(
conditions: Condition[],
onChange: (visibility: boolean) => void
): MediaQueriesListener[] {
const mediaQueries = extractMediaQueries(conditions);
const listeners = mediaQueries.map((query) => {
const listener = listenMediaQuery(query, (matches) => {
onChange(matches);
});
return listener;
});
return listeners;
}

View File

@@ -1,16 +1,16 @@
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import { deepEqual } from "../../../common/util/deep-equal";
import type { HomeAssistant } from "../../../types";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import type { HuiCard } from "../cards/hui-card";
import type { ConditionalCardConfig } from "../cards/types";
import type { Condition } from "../common/validate-condition";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
extractMediaQueries,
validateConditionalConfig,
} from "../common/validate-condition";
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
@@ -22,7 +22,9 @@ declare global {
}
@customElement("hui-conditional-base")
export class HuiConditionalBase extends ReactiveElement {
export class HuiConditionalBase extends ConditionalListenerMixin(
ReactiveElement
) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public preview = false;
@@ -31,10 +33,6 @@ export class HuiConditionalBase extends ReactiveElement {
protected _element?: HuiCard | LovelaceRow;
private _listeners: MediaQueriesListener[] = [];
private _mediaQueries: string[] = [];
protected createRenderRoot() {
return this;
}
@@ -63,21 +61,14 @@ export class HuiConditionalBase extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
protected setupConditionalListeners() {
if (!this._config || !this.hass) {
return;
}
@@ -85,27 +76,13 @@ export class HuiConditionalBase extends ReactiveElement {
const supportedConditions = this._config.conditions.filter(
(c) => "condition" in c
) as Condition[];
const mediaQueries = extractMediaQueries(supportedConditions);
if (deepEqual(mediaQueries, this._mediaQueries)) return;
this._clearMediaQueries();
const conditions = this._config.conditions;
const hasOnlyMediaQuery =
conditions.length === 1 &&
"condition" in conditions[0] &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
supportedConditions,
(matches) => {
if (hasOnlyMediaQuery) {
this.setVisibility(matches);
return;
}
this._updateVisibility();
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this.setVisibility(conditionsMet);
}
);
}
@@ -119,7 +96,8 @@ export class HuiConditionalBase extends ReactiveElement {
changed.has("hass") ||
changed.has("preview")
) {
this._listenMediaQueries();
this.clearConditionalListeners();
this.setupConditionalListeners();
this._updateVisibility();
}
}

View File

@@ -39,29 +39,31 @@ export class HuiEntityEditor extends LitElement {
private _renderItem(item: EntityConfig, index: number) {
const stateObj = this.hass.states[item.entity];
const useDeviceName = entityUseDeviceName(
stateObj,
this.hass.entities,
this.hass.devices
);
const useDeviceName =
stateObj &&
entityUseDeviceName(stateObj, this.hass.entities, this.hass.devices);
const isRTL = computeRTL(this.hass);
const primary =
(stateObj &&
this.hass.formatEntityName(
stateObj,
useDeviceName ? { type: "device" } : { type: "entity" }
)) ||
item.entity;
const secondary =
stateObj &&
this.hass.formatEntityName(
stateObj,
useDeviceName ? { type: "device" } : { type: "entity" }
) || item.entity;
const secondary = this.hass.formatEntityName(
stateObj,
useDeviceName
? [{ type: "area" }]
: [{ type: "area" }, { type: "device" }],
{
separator: isRTL ? " ◂ " : " ▸ ",
}
);
useDeviceName
? [{ type: "area" }]
: [{ type: "area" }, { type: "device" }],
{
separator: isRTL ? " ◂ " : " ▸ ",
}
);
return html`
<ha-md-list-item class="item">

View File

@@ -4,11 +4,14 @@ import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { DOMAINS_INPUT_ROW } from "../../../common/const";
import { uid } from "../../../common/util/uid";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { toggleAttribute } from "../../../common/dom/toggle_attribute";
import { computeDomain } from "../../../common/entity/compute_domain";
import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time";
import "../../../components/entity/state-badge";
import "../../../components/ha-relative-time";
import "../../../components/ha-tooltip";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import type { EntitiesCardEntityConfig } from "../cards/types";
@@ -36,6 +39,8 @@ export class HuiGenericEntityRow extends LitElement {
@property({ attribute: "catch-interaction", type: Boolean })
public catchInteraction?;
private _secondaryInfoElementId = "-" + uid();
protected render() {
if (!this.hass || !this.config) {
return nothing;
@@ -100,7 +105,19 @@ export class HuiGenericEntityRow extends LitElement {
? stateObj.entity_id
: this.config.secondary_info === "last-changed"
? html`
<ha-tooltip
for="last-changed${this
._secondaryInfoElementId}"
placement="right"
>
${formatDateTimeWithSeconds(
new Date(stateObj.last_changed),
this.hass.locale,
this.hass.config
)}
</ha-tooltip>
<ha-relative-time
id="last-changed${this._secondaryInfoElementId}"
.hass=${this.hass}
.datetime=${stateObj.last_changed}
capitalize
@@ -108,7 +125,20 @@ export class HuiGenericEntityRow extends LitElement {
`
: this.config.secondary_info === "last-updated"
? html`
<ha-tooltip
for="last-updated${this
._secondaryInfoElementId}"
placement="right"
>
${formatDateTimeWithSeconds(
new Date(stateObj.last_updated),
this.hass.locale,
this.hass.config
)}
</ha-tooltip>
<ha-relative-time
id="last-updated${this
._secondaryInfoElementId}"
.hass=${this.hass}
.datetime=${stateObj.last_updated}
capitalize
@@ -117,7 +147,22 @@ export class HuiGenericEntityRow extends LitElement {
: this.config.secondary_info === "last-triggered"
? stateObj.attributes.last_triggered
? html`
<ha-tooltip
for="last-triggered${this
._secondaryInfoElementId}"
placement="right"
>
${formatDateTimeWithSeconds(
new Date(
stateObj.attributes.last_triggered
),
this.hass.locale,
this.hass.config
)}
</ha-tooltip>
<ha-relative-time
id="last-triggered${this
._secondaryInfoElementId}"
.hass=${this.hass}
.datetime=${stateObj.attributes
.last_triggered}

View File

@@ -155,16 +155,20 @@ export class HuiGraphHeaderFooter
}
const width = this.clientWidth || this.offsetWidth;
// sample to 1 point per hour or 1 point per 5 pixels
const maxDetails =
const maxDetails = Math.max(
10,
this._config.detail! > 1
? Math.max(width / 5, this._config.hours_to_show!)
: this._config.hours_to_show!;
: this._config.hours_to_show!
);
const useMean = this._config.detail !== 2;
const { points } = coordinatesMinimalResponseCompressedState(
combinedHistory[this._config.entity],
width,
width / 5,
maxDetails,
{ minY: this._config.limits?.min, maxY: this._config.limits?.max }
{ minY: this._config.limits?.min, maxY: this._config.limits?.max },
useMean
);
this._coordinates = points;
},

View File

@@ -2,13 +2,13 @@ import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { checkConditionsMet } from "../common/validate-condition";
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
import type { LovelaceHeadingBadge } from "../types";
import type { LovelaceHeadingBadgeConfig } from "./types";
@@ -21,7 +21,7 @@ declare global {
}
@customElement("hui-heading-badge")
export class HuiHeadingBadge extends ReactiveElement {
export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceHeadingBadgeConfig;
@@ -39,20 +39,16 @@ export class HuiHeadingBadge extends ReactiveElement {
private _element?: LovelaceHeadingBadge;
private _listeners: MediaQueriesListener[] = [];
protected createRenderRoot() {
return this;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
@@ -137,26 +133,17 @@ export class HuiHeadingBadge extends ReactiveElement {
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this.config?.visibility) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
const conditions = this.config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
this.config.visibility,
(matches) => {
this._updateVisibility(hasOnlyMediaQuery && matches);
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}

View File

@@ -4,7 +4,6 @@ import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { LovelaceSectionElement } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -14,12 +13,13 @@ import type {
} from "../../../data/lovelace/config/section";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../types";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import "../cards/hui-card";
import type { HuiCard } from "../cards/hui-card";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
import { checkConditionsMet } from "../common/validate-condition";
import { createSectionElement } from "../create-element/create-section-element";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
@@ -37,7 +37,7 @@ declare global {
}
@customElement("hui-section")
export class HuiSection extends ReactiveElement {
export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
@@ -59,8 +59,6 @@ export class HuiSection extends ReactiveElement {
private _layoutElement?: LovelaceSectionElement;
private _listeners: MediaQueriesListener[] = [];
private _config: LovelaceSectionConfig | undefined;
@storage({
@@ -114,14 +112,10 @@ export class HuiSection extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._listenMediaQueries();
}
this._updateElement();
}
@@ -158,26 +152,17 @@ export class HuiSection extends ReactiveElement {
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this._config?.visibility) {
protected setupConditionalListeners() {
if (!this._config?.visibility || !this.hass) {
return;
}
const conditions = this._config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
conditions[0].media_query != null;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
this._config.visibility,
(matches) => {
this._updateElement(hasOnlyMediaQuery && matches);
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateElement(conditionsMet);
}
);
}
@@ -199,9 +184,6 @@ export class HuiSection extends ReactiveElement {
type: sectionConfig.type || DEFAULT_SECTION_LAYOUT,
};
this._config = sectionConfig;
if (this.isConnected) {
this._listenMediaQueries();
}
// Create a new layout element if necessary.
let addLayoutElement = false;

View File

@@ -652,7 +652,7 @@
"edit": "Edit",
"clear": "Clear",
"no_entities": "You don't have any entities",
"no_match": "No matching entities found",
"no_match": "No entities found for {term}",
"show_entities": "Show entities",
"new_entity": "Create a new entity",
"placeholder": "Select an entity",
@@ -684,10 +684,10 @@
},
"target-picker": {
"expand": "Expand",
"expand_floor_id": "Split this floor into separate areas.",
"expand_area_id": "Split this area into separate devices and entities.",
"expand_device_id": "Split this device into separate entities.",
"expand_label_id": "Split this label into separate areas, devices and entities.",
"expand_floor_id": "Split this floor into separate areas",
"expand_area_id": "Split this area into separate devices and entities",
"expand_device_id": "Split this device into separate entities",
"expand_label_id": "Split this label into separate areas, devices and entities",
"add_target": "Add target",
"remove": "Remove",
"remove_floor_id": "Remove floor",
@@ -763,7 +763,7 @@
},
"language-picker": {
"language": "Language",
"no_match": "No matching languages found",
"no_match": "No languages found for {term}",
"no_languages": "No languages available"
},
"tts-picker": {
@@ -775,7 +775,7 @@
"none": "None"
},
"user-picker": {
"no_match": "No matching users found",
"no_match": "No users found for {term}",
"user": "User",
"add_user": "Add user"
},
@@ -786,8 +786,8 @@
"clear": "Clear",
"toggle": "Toggle",
"show_devices": "Show devices",
"no_devices": "You don't have any devices",
"no_match": "No matching devices found",
"no_devices": "No devices available",
"no_match": "No devices found for {term}",
"device": "Device",
"unnamed_device": "Unnamed device",
"no_area": "No area",
@@ -801,8 +801,8 @@
"add_category": "Add category",
"add_new_sugestion": "Add new category ''{name}''",
"add_new": "Add new category…",
"no_categories": "You don't have any categories",
"no_match": "No matching categories found",
"no_categories": "No categories available",
"no_match": "No categories found for {term}",
"add_dialog": {
"title": "Add new category",
"text": "Enter the name of the new category.",
@@ -817,8 +817,8 @@
"add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…",
"add": "Add label",
"no_labels": "You don't have any labels",
"no_match": "No matching labels found",
"no_labels": "No labels available",
"no_match": "No labels found for {term}",
"failed_create_label": "Failed to create label."
},
"area-picker": {
@@ -827,8 +827,8 @@
"area": "Area",
"add_new_sugestion": "Add new area ''{name}''",
"add_new": "Add new area…",
"no_areas": "You don't have any areas",
"no_match": "No matching areas found",
"no_areas": "No areas available",
"no_match": "No areas found for {term}",
"unassigned_areas": "Unassigned areas",
"failed_create_area": "Failed to create area."
},
@@ -838,8 +838,8 @@
"floor": "Floor",
"add_new_sugestion": "Add new floor ''{name}''",
"add_new": "Add new floor…",
"no_floors": "You don't have any floors",
"no_match": "No matching floors found",
"no_floors": "No floors available",
"no_match": "No floors found for {term}",
"failed_create_floor": "Failed to create floor."
},
"area-filter": {
@@ -853,8 +853,8 @@
"statistic-picker": {
"statistic": "Statistic",
"placeholder": "Select a statistic",
"no_statistics": "You don't have any statistics",
"no_match": "No matching statistics found",
"no_statistics": "No statistics available",
"no_match": "No statistics found for {term}",
"no_state": "Entity without state",
"missing_entity": "Why is my entity not listed?",
"learn_more": "Learn more about statistics"
@@ -1292,7 +1292,8 @@
"add": "Add interaction"
},
"combo-box": {
"no_match": "No matching items found"
"no_match": "No matching items found",
"no_items": "No items available"
},
"suggest_with_ai": {
"label": "Suggest",
@@ -1434,6 +1435,7 @@
"back_to_info": "Back to info",
"info": "Information",
"related": "Related",
"add_entity_to": "Add to",
"history": "History",
"aggregate": "5-minute aggregated",
"logbook": "Activity",
@@ -1450,6 +1452,10 @@
"last_action": "Last action",
"last_triggered": "Last triggered"
},
"add_to": {
"no_actions": "No actions available",
"action_failed": "Failed to perform the action {error}"
},
"sun": {
"azimuth": "Azimuth",
"elevation": "Elevation",
@@ -3071,6 +3077,7 @@
"remove_co2_signal": "Remove Electricity Maps integration",
"add_co2_signal": "Add Electricity Maps integration",
"flow_dialog": {
"cost_entity_helper": "Any sensor with a unit of `{currency}/(valid energy unit)` (e.g. `{currency}/Wh` or `{currency}/kWh`) may be used and will be automatically converted.",
"from": {
"header": "Configure grid consumption",
"paragraph": "Grid consumption is the energy that flows from the energy grid to your home.",
@@ -3961,7 +3968,7 @@
"type_automation_plural": "[%key:ui::panel::config::blueprint::overview::types_plural::automation%]",
"type_script_plural": "[%key:ui::panel::config::blueprint::overview::types_plural::script%]",
"new_automation_setup_failed_title": "New {type} setup timed out",
"new_automation_setup_failed_text": "Your new {type} has saved, but waiting for it to setup has timed out. This could be due to errors parsing your configuration.yaml, please check the configuration in developer tools. Your {type} will not be visible until this is corrected, and {types} are reloaded. Changes to area, category, or labels were not saved and must be reapplied.",
"new_automation_setup_failed_text": "Your new {type} was saved, but waiting for it to set up has timed out. This could be due to errors parsing your configuration.yaml, please check the configuration in developer tools. Your {type} will not be visible until this is corrected and {types} are reloaded. Changes to area, category, or labels were not saved and must be reapplied.",
"new_automation_setup_keep_waiting": "You may continue to wait for a response from the server, in case it is just taking an unusually long time to process this {type}.",
"new_automation_setup_timedout_success": "The server has responded and this has now setup successfully. You may now close this dialog.",
"item_pasted": "{item} pasted",
@@ -6556,7 +6563,8 @@
"model": "Model",
"status": "Status",
"version": "Version",
"data_rate": "Data rate"
"data_rate": "Data rate",
"area": "Area"
},
"node_status": {
"0": "Unknown",

View File

@@ -138,6 +138,7 @@ export interface PanelInfo<T = Record<string, any> | null> {
title: string | null;
url_path: string;
config_panel_domain?: string;
default_visible?: boolean;
}
export type Panels = Record<string, PanelInfo>;

756
yarn.lock

File diff suppressed because it is too large Load Diff