Compare commits

..

67 Commits

Author SHA1 Message Date
Aidan Timson a7c9be595e Clear params
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-08 12:54:07 +01:00
Aidan Timson 77bbc0c286 Make const 2026-04-08 12:51:44 +01:00
Aidan Timson ffdafa369b ally: reduce announcements to 20 second intervals, last at 5 seconds 2026-04-08 12:50:52 +01:00
Aidan Timson 5d07dbdcb2 Plural rules for second(s) 2026-04-08 12:44:00 +01:00
Aidan Timson e48592f98b Default 2026-04-08 12:27:16 +01:00
Aidan Timson f97eff7e0c Refactor, clear toast on disconnect 2026-04-08 12:26:40 +01:00
Aidan Timson 0eeabcb8e9 Always clear 2026-04-08 12:23:03 +01:00
Aidan Timson 1b61b1451e Keep edit mode behavior 2026-04-08 12:20:42 +01:00
Aidan Timson b3fbf08285 Remove extras
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-08 12:19:30 +01:00
Aidan Timson 62ab786d2e Use fetch instead of window reload on timeout 2026-04-08 12:17:28 +01:00
Aidan Timson b4cba50795 Cancel action on dismiss 2026-04-08 12:06:48 +01:00
Aidan Timson 7c097c3244 Auto reload dashboard 2026-04-08 12:00:04 +01:00
Aidan Timson aab2304d86 Remove extra "Community:" prefix for add badge dialog (#51465)
Remove extra "Custom:" naming
2026-04-08 10:35:01 +00:00
Aidan Timson c013f79826 Add links to logs in integration page (#51463)
* Add link to logs in integration page

* Join

* Add link to "Check the logs"

* Add to entry overflow when in error state
2026-04-08 13:34:59 +03:00
renovate[bot] 60236c2fee Update dependency @html-eslint/eslint-plugin to v0.59.0 (#51464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 11:26:42 +01:00
Aidan Timson 20d53a2659 Allow quick search for non-admins, while hiding inaccessible areas (#51456)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-08 09:50:36 +02:00
Mihail Sîrbu 6dbc38386c Add apps info page for non-HAOS installations (#30364)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-08 07:02:47 +00:00
renovate[bot] ce5a19caa8 Update dependency marked to v17.0.6 (#51460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 08:59:33 +02:00
Wendelin 2cda06e7a6 Introduce ha-progress-bar (#51453)
* Replace mwc-linear-progress with ha-progress-bar

* Update src/panels/lovelace/cards/hui-media-control-card.ts

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

* Remove duplicate import of ha-slider in hui-media-control-card.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-08 09:40:42 +03:00
renovate[bot] 65485ce8c9 Update dependency fuse.js to v7.3.0 (#51457)
* Update dependency fuse.js to v7.3.0

* type fix

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-08 05:28:37 +00:00
GeorgeZ83 b73ae60cea Fix media browser dialog window (#51423)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-07 14:52:16 +00:00
Petar Petrov cef35c6c23 Center energy dashboard bar charts on period midpoint (#30325) 2026-04-07 16:51:35 +02:00
Petar Petrov 6b9685ec9f Fix time condition summary using "and" instead of "or" for midnight-crossing ranges (#51452) 2026-04-07 16:47:17 +02:00
Petar Petrov fc9289dc05 Fix incorrect timezone in automation time trigger/condition descriptions (#51454) 2026-04-07 14:09:28 +00:00
Aidan Timson 2a2bca2a61 Handle lazy loaded entity registry when editing scripts from more info (#51438)
* Handle lazy loaded entity registry when editing scripts from more info

* Remove extra check

* Fix type of mixin
2026-04-07 17:07:21 +03:00
Petar Petrov 1eda51ddbc Allow customizing initial map view with latitude, longitude, and zoom (#51444)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-07 15:07:13 +01:00
Wendelin 22738f6d77 Remove fab (#51448)
* Remove ha-fab replace with ha-button

* Remove import of ha-fab component

* Remove mwc-fab
2026-04-07 15:46:36 +03:00
Petar Petrov 2f73351c35 Fix dialog show animation broken by connectedCallback _open sync (#51450) 2026-04-07 12:18:16 +00:00
Petar Petrov 44b442dc0e Fix toast race condition causing stuck notifications (#51447) 2026-04-07 13:13:18 +01:00
Aidan Timson 916731d0ee Restore custom wording for integrations (#51440) 2026-04-07 13:11:28 +01:00
Aidan Timson 5113594d6b Use script field name if available in row instead of key (#51445)
Use script field name if available in row
2026-04-07 14:46:17 +03:00
Petar Petrov edd162df68 Use websocket subscription for calendar events (#27906)
* 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>

* 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>

* 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.

* 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>

* 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>

* 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>

* Update src/panels/lovelace/cards/hui-calendar-card.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/panels/lovelace/cards/hui-calendar-card.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/panels/calendar/ha-panel-calendar.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* 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>

* 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>

* 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>

* 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>

* PR comments

* fix import

* fix import

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-07 13:18:53 +02:00
Aidan Timson 6278d9be2f Focus scrollable content on load for data tables (#51372)
* Focus scrollable content on load for data tables

* Improve typing

* Replace any types

* Refocus on heading click

* Remove

* Focus on checkbox enable/disable

* Cleanup

* Remove unneeded track autofocus

* Reduce guard need

* Remove

* Merge
2026-04-07 13:51:16 +03:00
Petar Petrov ba2fef50d0 Preserve browser back/forward keyboard shortcuts in tab group (#51439) 2026-04-07 11:07:34 +01:00
ildar170975 a9774e74cf Map card editor: add UI for conditions (#30247)
* add conditions to MapCardConfig

* add conditions to Map card editor

* support no_entity state/numeric_state conditions

* support no_entity state/numeric_state conditions

* support no_entity state/numeric_state conditions

* support no_entity state/numeric_state conditions

* Create ha-card-condition-numeric_state-no_entity.ts

* support no_entity state/numeric_state conditions

* support no_entity state/numeric_state conditions

* Create ha-card-condition-state-no_entity.ts

* support no_entity state/numeric_state conditions

* add UI for conditions

* remove comments

* linter

* resolving conflicts

* add no_entity property

* fix no_entity property

* Update ha-card-condition-or.ts

* Update ha-card-condition-state.ts

* Update ha-card-condition-and.ts

* Update ha-card-condition-not.ts

* Update ha-card-condition-editor.ts

* Update ha-card-conditions-editor.ts

* Update hui-map-card-editor.ts

* Update ha-selector.ts

* Update ha-selector-state.ts

* Create ha-selector-state-no_entity.ts

* linter

* Delete src/components/ha-selector/ha-selector-state-no_entity.ts

* revert

* Update ha-selector-state.ts

* Update ha-selector-state.ts

* Update selector.ts

* Update ha-card-condition-state.ts

* prettier

* fix addEntityToCondition

* Update src/translations/en.json

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

* pass no_entity to _schema

* pass no_entity to _schema

* forgot smth

* prettier

* prettier

* prettier

* prettier

* get translated states manually

* Update hui-map-card-editor.ts

* prettier

* add zones

* Update hui-map-card-editor.ts

* add getStatesDomain()

* use getStatesDomain()

* prettier

* prettier

* prettier

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* add attribute "no_entity"

* add attribute "no_entity"

* add attribute "no_entity"

* add attribute "no_entity"

* add attribute "no_entity"

* add attribute "no_entity"

* no_entity -> noEntity

* no_entity -> noEntity

* add attribute "no_entity"

* prettier

* add "@state" to _presetStates

* Apply suggestions from code review

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

* format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-07 06:48:00 +00:00
Elias Axonov ae3d6c77ca Fix iOS long-press context menu showing on images (#9549) (#51432) 2026-04-07 08:55:14 +03:00
renovate[bot] 4f3feced1b Update dependency @eslint/eslintrc to v3.3.5 (#51436)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 08:53:44 +03:00
renovate[bot] 49dd217935 Update dependency eslint to v10.2.0 (#51437)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 08:52:56 +03:00
iluvdata 522cffffa8 fix flowType in showSubConfigFlowDialog in next_flow flows (#51389)
* fix flowType in config subentry flows/next_flow

* suggested changes
2026-04-07 08:34:34 +03:00
Petar Petrov 3124fbe08e Update eslint to v10.1.0 (#51352)
* Update eslint to v10.1.0

Migrate from eslint-plugin-import to eslint-plugin-import-x since the
original plugin uses removed ESLint 10 APIs (context.parserOptions,
context.parserPath). Airbnb-base non-import rules preserved via
FlatCompat with import rules stripped and replaced by import-x.

- eslint 9.39.4 → 10.1.0
- Add eslint-plugin-import-x 4.16.2
- Add @eslint/eslintrc and @eslint/js as explicit deps
- Remove --flag v10_config_lookup_from_file (now default)
- Replace /* eslint-env */ comment with config-based globals
- Update all import/ rule references to import-x/

* Remove unnecessary eslint-disable for generated gallery imports

The import-pages file is generated as .ts, so import-x/extensions
doesn't require an extension (ts: "never" in config).
2026-04-07 08:33:58 +03:00
renovate[bot] c705d4e4a1 Update dependency @types/chromecast-caf-receiver to v6.0.26 (#51431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 20:13:59 +02:00
Alex Brown 446661915b Renamed manage lock added graceful degregation (#51293)
* Cleaned up experience when no pin support is possible to provide better messaging.

* Improvements to messaging depending on lock features

* Added documentation links

* Added prettier format
2026-04-06 16:46:00 +03:00
Brooke Hatton 6048356e01 Add Maintenance summary card and dashboard (#30372)
* Add a summary for battery status and a battery strategy

* some cleanup around location given its a sub view and naming

* Use the device name rather than entity name

* Refactor into a maintenance panel

* Adjust naming

* rename

* Add maintenance dashboard to built in dashboard list

* use grey for maintenance colour

* Fix typos in maintenance panel

- _searchParms -> _searchParams
- Add Event type to _back parameter
- Fix duplicate closing tag

* Move maintenance strategy to src/panels/maintenance/strategies/

- Move maintenance-view-strategy.ts to maintenance panel directory
- Update import paths in get-strategy.ts, home-summaries.ts, and hui-home-summary-card.ts
- Fix ha-floor-icon import path in maintenance-view-strategy.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-06 15:26:29 +03:00
Tom Carpenter c44341331a Add Last 365 Days option to energy date selection (#51426)
* Add Last 365 Days option to energy date selection

There seem to be two use cases for the "Last 12 Months" option - those that expect it to produce whole months (original behaviour up to 2026.1, then again from 2026.4), and those that want an option for the last 365 days,

Given this descrepancy in expected behaviour, add an explicit "Last 365 days" option.

* Use subDays for last 365.
2026-04-06 13:44:51 +03:00
Aidan Timson 2d46304960 Update custom to community for user facing integrations, cards and badges (#51368)
* Update custom to community for user facing integrations, card, badges

* Rename as suggested
2026-04-06 11:57:01 +03:00
Wendelin b5ff6a991d Migrate ha-textarea (#51377)
* Migrate ha-textarea

* Fix autogrow

* fix styles
2026-04-06 11:54:55 +03:00
Aidan Timson 28254ca0f2 Focus scrollable content on load for integrations (#51379) 2026-04-06 11:49:16 +03:00
Erwin Douna 8605c235ac Add loading state to energy dashboard (#51392)
* Add loading state to energy dashboard

* Add fade-in/-out

* Update src/panels/lovelace/components/hui-energy-period-selector.ts

Co-authored-by: Tom Carpenter <T.Carpenter@leeds.ac.uk>

* Feedback

* Feedback

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Tom Carpenter <T.Carpenter@leeds.ac.uk>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-06 06:36:59 +00:00
dependabot[bot] 8325161d39 Bump home-assistant/actions from d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d to 5752577ea7cc5aefb064b0b21432f18fe4d6ba90 (#51430)
Bump home-assistant/actions

Bumps [home-assistant/actions](https://github.com/home-assistant/actions) from d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d to 5752577ea7cc5aefb064b0b21432f18fe4d6ba90.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d...5752577ea7cc5aefb064b0b21432f18fe4d6ba90)

---
updated-dependencies:
- dependency-name: home-assistant/actions
  dependency-version: 5752577ea7cc5aefb064b0b21432f18fe4d6ba90
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 09:32:30 +03:00
Niccolò Betto 90057854c8 Fix code input dialog undefined value concatenation (#51399) 2026-04-06 06:07:23 +00:00
Simon Lamon 04c8c82966 Increase gauge thickness for accessibility reasons (#51382)
Increase thickness for accessability reasons
2026-04-06 08:59:55 +03:00
renovate[bot] 16b3add987 Update dependency eslint-import-resolver-webpack to v0.13.11 (#51424)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 08:48:42 +03:00
renovate[bot] f0e171076e Update dependency @swc/helpers to v0.5.21 (#51429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 08:47:31 +03:00
renovate[bot] 48f0b78b95 Update dependency fuse.js to v7.2.0 (#51428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 08:47:09 +03:00
renovate[bot] 32728d91d7 Update dependency barcode-detector to v3.1.2 (#51417)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-04 20:59:00 +02:00
renovate[bot] 0915a3e29c Update dependency @codemirror/view to v6.41.0 (#51416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-04 20:26:38 +02:00
renovate[bot] 60c5888f6b Update dependency @rsdoctor/rspack-plugin to v1.5.7 (#51398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 18:07:11 +02:00
renovate[bot] fb599b8b16 Update dependency @rspack/core to v1.7.11 (#51397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 13:29:21 +02:00
Bram Kragten b1a390789d Always add options object to triggers and conditions (#51394) 2026-04-03 12:59:58 +02:00
Trevin Chow 76c0dd1f1f Guard against orphaned label references in device list (#51359) 2026-04-03 09:00:05 +00:00
Trevin Chow 96dacfdeca Fix TypeError in Voice Assistants expose page with manual entity filters (#51357) 2026-04-03 10:51:44 +02:00
Petar Petrov 5f28ed35d2 Only use inflight map for pending fragment translation loads (#51393) 2026-04-03 10:39:50 +02:00
Petar Petrov edd7b4c3dc Fix fragment translation race condition returning stale localize (#51381) 2026-04-03 10:21:52 +02:00
Tim Ittermann cbea8bbf44 fix: null value error on ha-form-integer (#51385)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-04-03 08:19:03 +00:00
Petar Petrov 23a41e4384 Fix next_flow dialog closing immediately after rendering (#51369) 2026-04-03 10:14:21 +02:00
renovate[bot] f747580b43 Update dependency typescript-eslint to v8.58.0 (#51386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 06:56:48 +02:00
Brooke Hatton 98fc69674f Fix typos and syntax errors in ha-panel-climate.ts (#51388)
* Fix typos and syntax errors in ha-panel-climate.ts

- Rename _searchParms to _searchParams (typo fix)
- Fix duplicate closing hui-view-container tag
- Clean up formatting

* Fix duplicate hui-view-container closing tag in light and security panels
2026-04-03 06:56:29 +02:00
184 changed files with 4737 additions and 2030 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
uses: home-assistant/actions/helpers/verify-version@5752577ea7cc5aefb064b0b21432f18fe4d6ba90 # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+3 -3
View File
@@ -6,9 +6,9 @@ import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
rules: {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"import-x/no-extraneous-dependencies": "off",
"import-x/extensions": "off",
"import-x/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off",
@@ -6,7 +6,6 @@ import presetEnv from "@babel/preset-env";
import compilationTargets from "@babel/helper-compilation-targets";
import coreJSCompat from "core-js-compat";
import { logPlugin } from "@babel/preset-env/lib/debug.js";
// eslint-disable-next-line import/no-relative-packages
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
import { babelOptions } from "./bundle.cjs";
+2 -2
View File
@@ -11,9 +11,9 @@ export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./jimpower").then((mod) => mod.demoJimpower),
];
// eslint-disable-next-line import/no-mutable-exports
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfigIndex = 0;
// eslint-disable-next-line import/no-mutable-exports
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfig: Promise<DemoConfig> =
demoConfigs[selectedDemoConfigIndex]();
+59 -14
View File
@@ -1,6 +1,5 @@
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import path from "node:path";
@@ -13,6 +12,7 @@ import { configs as litConfigs } from "eslint-plugin-lit";
import { configs as wcConfigs } from "eslint-plugin-wc";
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
@@ -22,8 +22,27 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
});
// Load airbnb-base via FlatCompat for non-import rules only.
// eslint-plugin-import is incompatible with ESLint 10 (uses removed APIs),
// so we strip its plugin/rules/settings and use eslint-plugin-import-x instead.
const airbnbConfigs = compat.extends("airbnb-base").map((config) => {
const { plugins = {}, rules = {}, settings = {}, ...rest } = config;
return {
...rest,
plugins: Object.fromEntries(
Object.entries(plugins).filter(([key]) => key !== "import")
),
rules: Object.fromEntries(
Object.entries(rules).filter(([key]) => !key.startsWith("import/"))
),
settings: Object.fromEntries(
Object.entries(settings).filter(([key]) => !key.startsWith("import/"))
),
};
});
export default tseslint.config(
...compat.extends("airbnb-base"),
...airbnbConfigs,
eslintConfigPrettier,
litConfigs["flat/all"],
tseslint.configs.recommended,
@@ -31,6 +50,7 @@ export default tseslint.config(
tseslint.configs.stylistic,
wcConfigs["flat/recommended"],
a11yConfigs.recommended,
importX.flatConfigs.recommended,
{
plugins: {
"unused-imports": unusedImports,
@@ -58,7 +78,7 @@ export default tseslint.config(
},
settings: {
"import/resolver": {
"import-x/resolver": {
webpack: {
config: "./rspack.config.cjs",
},
@@ -87,12 +107,20 @@ export default tseslint.config(
"prefer-destructuring": "off",
"no-restricted-globals": [2, "event"],
"prefer-promise-reject-errors": "off",
"import/prefer-default-export": "off",
"import/no-default-export": "off",
"import/no-unresolved": "off",
"import/no-cycle": "off",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"no-use-before-define": "off",
"import/extensions": [
// import-x rules (migrated from eslint-plugin-import / airbnb-base)
"import-x/named": "off",
"import-x/prefer-default-export": "off",
"import-x/no-default-export": "off",
"import-x/no-unresolved": "off",
"import-x/no-cycle": "off",
"import-x/extensions": [
"error",
"ignorePackages",
{
@@ -100,12 +128,24 @@ export default tseslint.config(
js: "never",
},
],
"import-x/no-mutable-exports": "error",
"import-x/no-amd": "error",
"import-x/first": "error",
"import-x/order": [
"error",
{ groups: [["builtin", "external", "internal"]] },
],
"import-x/newline-after-import": "error",
"import-x/no-absolute-path": "error",
"import-x/no-dynamic-require": "error",
"import-x/no-webpack-loader-syntax": "error",
"import-x/no-named-default": "error",
"import-x/no-self-import": "error",
"import-x/no-useless-path-segments": ["error", { commonjs: true }],
"import-x/no-import-module-exports": ["error", { exceptions: [] }],
"import-x/no-relative-packages": "error",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
// TypeScript rules
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-use-before-define": "off",
@@ -185,7 +225,6 @@ export default tseslint.config(
allowObjectTypes: "always",
},
],
"no-use-before-define": "off",
},
},
{
@@ -194,6 +233,12 @@ export default tseslint.config(
globals: globals.audioWorklet,
},
},
{
files: ["src/entrypoints/service-worker.ts"],
languageOptions: {
globals: globals.serviceworker,
},
},
{
plugins: {
html,
@@ -57,7 +57,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
| size | "small"/"medium" | "medium" | Sets the button size. |
| size | "small"/"medium"/"large" | "medium" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
| disabled | Boolean | false | Disables the button and prevents user interaction. |
@@ -0,0 +1,73 @@
---
title: Textarea
---
# Textarea `<ha-textarea>`
A multiline text input component supporting Home Assistant theming and validation, based on webawesome textarea.
Supports autogrow, hints, validation, and both material and outlined appearances.
## Implementation
### Example usage
```html
<ha-textarea label="Description" value="Hello world"></ha-textarea>
<ha-textarea
label="Notes"
placeholder="Type here..."
resize="auto"
></ha-textarea>
<ha-textarea label="Required field" required></ha-textarea>
<ha-textarea label="Disabled" disabled value="Can't edit this"></ha-textarea>
```
### API
This component is based on the webawesome textarea component.
**Slots**
- `label`: Custom label content. Overrides the `label` property.
- `hint`: Custom hint content. Overrides the `hint` property.
**Properties/Attributes**
| Name | Type | Default | Description |
| ------------------ | -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
| value | String | - | The current value of the textarea. |
| label | String | "" | The textarea's label text. |
| hint | String | "" | The textarea's hint/helper text. |
| placeholder | String | "" | Placeholder text shown when the textarea is empty. |
| rows | Number | 4 | The number of visible text rows. |
| resize | "none"/"vertical"/"horizontal"/"both"/"auto" | "none" | Controls the textarea's resize behavior. |
| readonly | Boolean | false | Makes the textarea readonly. |
| disabled | Boolean | false | Disables the textarea and prevents user interaction. |
| required | Boolean | false | Makes the textarea a required field. |
| auto-validate | Boolean | false | Validates the textarea on blur instead of on form submit. |
| invalid | Boolean | false | Marks the textarea as invalid. |
| validation-message | String | "" | Custom validation message shown when the textarea is invalid. |
| minlength | Number | - | The minimum length of input that will be considered valid. |
| maxlength | Number | - | The maximum length of input that will be considered valid. |
| name | String | - | The name of the textarea, submitted as a name/value pair with form data. |
| autocapitalize | "off"/"none"/"on"/"sentences"/"words"/"characters" | "" | Controls whether and how text input is automatically capitalized. |
| autocomplete | String | - | Indicates whether the browser's autocomplete feature should be used. |
| autofocus | Boolean | false | Automatically focuses the textarea when the page loads. |
| spellcheck | Boolean | true | Enables or disables the browser's spellcheck feature. |
| inputmode | "none"/"text"/"decimal"/"numeric"/"tel"/"search"/"email"/"url" | "" | Hints at the type of data for showing an appropriate virtual keyboard. |
| enterkeyhint | "enter"/"done"/"go"/"next"/"previous"/"search"/"send" | "" | Customizes the label or icon of the Enter key on virtual keyboards. |
#### CSS Parts
- `wa-base` - The underlying wa-textarea base wrapper.
- `wa-hint` - The underlying wa-textarea hint container.
- `wa-textarea` - The underlying wa-textarea textarea element.
**CSS Custom Properties**
- `--ha-textarea-padding-bottom` - Padding below the textarea host.
- `--ha-textarea-max-height` - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`.
- `--ha-textarea-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
+151
View File
@@ -0,0 +1,151 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-textarea";
@customElement("demo-components-ha-textarea")
export class DemoHaTextarea extends LitElement {
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-textarea in ${mode}">
<div class="card-content">
<h3>Basic</h3>
<div class="row">
<ha-textarea label="Default"></ha-textarea>
<ha-textarea
label="With value"
value="Hello world"
></ha-textarea>
<ha-textarea
label="With placeholder"
placeholder="Type here..."
></ha-textarea>
</div>
<h3>Autogrow</h3>
<div class="row">
<ha-textarea
label="Autogrow empty"
resize="auto"
></ha-textarea>
<ha-textarea
label="Autogrow with value"
resize="auto"
value="This textarea will grow as you type more content into it. Try adding more lines to see the effect."
></ha-textarea>
</div>
<h3>States</h3>
<div class="row">
<ha-textarea
label="Disabled"
disabled
value="Disabled"
></ha-textarea>
<ha-textarea
label="Readonly"
readonly
value="Readonly"
></ha-textarea>
<ha-textarea label="Required" required></ha-textarea>
</div>
<div class="row">
<ha-textarea
label="Invalid"
invalid
validation-message="This field is required"
value=""
></ha-textarea>
<ha-textarea
label="With hint"
hint="Supports Markdown"
></ha-textarea>
<ha-textarea
label="With rows"
.rows=${6}
placeholder="6 rows"
></ha-textarea>
</div>
<h3>No label</h3>
<div class="row">
<ha-textarea
placeholder="No label, just placeholder"
></ha-textarea>
<ha-textarea
resize="auto"
placeholder="No label, autogrow"
></ha-textarea>
</div>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
h3 {
margin: var(--ha-space-4) 0 var(--ha-space-1) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
h3:first-child {
margin-top: 0;
}
.row {
display: flex;
gap: var(--ha-space-4);
}
.row > * {
flex: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-textarea": DemoHaTextarea;
}
}
@@ -1,7 +1,5 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
+2 -2
View File
@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress";
import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -8,6 +7,7 @@ import "../../src/components/ha-button";
import "../../src/components/ha-fade-in";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
@@ -60,7 +60,7 @@ class HaLandingPage extends LandingPageBaseElement {
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<mwc-linear-progress indeterminate></mwc-linear-progress>
<ha-progress-bar indeterminate></ha-progress-bar>
`
: nothing}
${networkIssue || this._networkInfoError
+1 -1
View File
@@ -1,6 +1,6 @@
export default {
"*.?(c|m){js,ts}": [
"eslint --flag v10_config_lookup_from_file --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"eslint --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"prettier --cache --write",
"lit-analyzer --quiet",
],
+18 -18
View File
@@ -8,8 +8,8 @@
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"lint:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
@@ -34,7 +34,7 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.40.0",
"@codemirror/view": "6.41.0",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.3.1",
@@ -65,16 +65,12 @@
"@material/mwc-checkbox": "0.27.0",
"@material/mwc-dialog": "0.27.0",
"@material/mwc-drawer": "0.27.0",
"@material/mwc-fab": "0.27.0",
"@material/mwc-floating-label": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-linear-progress": "0.27.0",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/mwc-radio": "0.27.0",
"@material/mwc-select": "0.27.0",
"@material/mwc-switch": "0.27.0",
"@material/mwc-textarea": "0.27.0",
"@material/mwc-textfield": "0.27.0",
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
@@ -82,14 +78,14 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.20",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.1",
"barcode-detector": "3.1.2",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -102,7 +98,7 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.0.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.1.0",
"fuse.js": "7.3.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.15",
@@ -116,7 +112,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.5",
"marked": "17.0.6",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -144,16 +140,18 @@
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.0",
"@html-eslint/eslint-plugin": "0.58.1",
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.59.0",
"@lokalise/node-api": "15.6.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.6",
"@rspack/core": "1.7.10",
"@rsdoctor/rspack-plugin": "1.5.7",
"@rspack/core": "1.7.11",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
@@ -174,11 +172,12 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.39.4",
"eslint": "10.2.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
@@ -186,6 +185,7 @@
"fancy-log": "2.0.0",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.4.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -208,7 +208,7 @@
"terser-webpack-plugin": "5.4.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.2",
"typescript-eslint": "8.57.2",
"typescript-eslint": "8.58.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.2",
"webpack-stats-plugin": "1.1.3",
@@ -21,6 +21,9 @@ export const filterNavigationPages = (
if (page.path === "#external-app-configuration") {
return hass.auth.external?.config.hasSettingsScreen;
}
if (page.adminOnly && !hass.user?.is_admin) {
return false;
}
// Only show Bluetooth page if there are Bluetooth config entries
if (page.component === "bluetooth") {
return options.hasBluetoothConfigEntries ?? false;
+6
View File
@@ -27,6 +27,7 @@ export type DateRange =
| "this_year"
| "now-7d"
| "now-30d"
| "now-365d"
| "now-12m"
| "now-1h"
| "now-12h"
@@ -102,6 +103,11 @@ export const calcDateRange = (
),
calcDate(today, endOfMonth, locale, hassConfig),
];
case "now-365d":
return [
calcDate(today, subDays, locale, hassConfig, 365),
calcDate(today, subDays, locale, hassConfig, 0),
];
case "now-1h":
return [
calcDate(today, subHours, locale, hassConfig, 1),
+8
View File
@@ -38,6 +38,14 @@ export interface HASSDomEvent<T> extends Event {
detail: T;
}
export type HASSDomTargetEvent<T extends EventTarget> = Event & {
target: T;
};
export type HASSDomCurrentTargetEvent<T extends EventTarget> = Event & {
currentTarget: T;
};
/**
* Dispatches a custom event with an optional detail value.
*
+8 -2
View File
@@ -7,7 +7,8 @@ export type LeafletModuleType = typeof import("leaflet");
export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement
mapElement: HTMLElement,
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
@@ -32,7 +33,12 @@ export const setupLeafletMap = async (
markerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(markerClusterStyle);
map.setView([52.3731339, 4.8903147], 13);
if (initialView) {
map.setView(
[initialView.latitude, initialView.longitude],
initialView.zoom ?? 13
);
}
const tileLayer = createTileLayer(Leaflet).addTo(map);
+39 -22
View File
@@ -242,14 +242,18 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
},
};
export const getStates = (
export const getStatesDomain = (
hass: HomeAssistant,
state: HassEntity,
attribute: string | undefined = undefined
domain: string,
attribute?: string | undefined
): string[] => {
const domain = computeStateDomain(state);
const result: string[] = [];
if (!attribute) {
// All entities can have unavailable states
result.push(...UNAVAILABLE_STATES);
}
if (!attribute && domain in FIXED_DOMAIN_STATES) {
result.push(...FIXED_DOMAIN_STATES[domain]);
} else if (
@@ -260,21 +264,7 @@ export const getStates = (
result.push(...FIXED_DOMAIN_ATTRIBUTE_STATES[domain][attribute]);
}
// Dynamic values based on the entities
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "device_tracker":
case "person":
if (!attribute) {
@@ -293,6 +283,37 @@ export const getStates = (
);
}
break;
}
return result;
};
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
attribute: string | undefined = undefined
): string[] => {
const domain = computeStateDomain(state);
const result: string[] = [];
// Fixed values based on a domain
result.push(...getStatesDomain(hass, domain, attribute));
// Dynamic values based on the entities
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "event":
if (attribute === "event_type") {
result.push(...state.attributes.event_types);
@@ -353,9 +374,5 @@ export const getStates = (
break;
}
if (!attribute) {
// All entities can have unavailable states
result.push(...UNAVAILABLE_STATES);
}
return [...new Set(result)];
};
-3
View File
@@ -10,13 +10,10 @@
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
// eslint-disable-next-line import/extensions
import { directive, Directive } from "lit-html/directive.js";
// eslint-disable-next-line import/extensions
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
// eslint-disable-next-line import/extensions
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
+11 -12
View File
@@ -1,8 +1,8 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@@ -12,12 +12,12 @@ import type {
} from "../../data/history";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import type { StateHistoryChartLine } from "./state-history-chart-line";
import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
import "../ha-fab";
import "../ha-button";
import "../ha-svg-icon";
import "./state-history-chart-line";
import type { StateHistoryChartLine } from "./state-history-chart-line";
import "./state-history-chart-timeline";
import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
const CANVAS_TIMELINE_ROWS_CHUNK = 10; // Split up the canvases to avoid hitting the render limit
@@ -150,16 +150,14 @@ export class StateHistoryCharts extends LitElement {
this._renderHistoryItem(item, index)
)}`}
${this.syncCharts && this._hasZoomedCharts
? html`<ha-fab
slot="fab"
? html`<ha-button
size="large"
class="reset-button"
.label=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
@click=${this._handleGlobalZoomReset}
>
<ha-svg-icon slot="icon" .path=${mdiRestart}></ha-svg-icon>
</ha-fab>`
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize("ui.components.history_charts.zoom_reset")}
</ha-button>`
: nothing}
`;
}
@@ -448,6 +446,7 @@ export class StateHistoryCharts extends LitElement {
bottom: calc(24px + var(--safe-area-inset-bottom));
right: calc(24px + var(--safe-area-inset-bottom));
z-index: 1;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
`;
}
+73 -31
View File
@@ -17,6 +17,10 @@ import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
@@ -103,6 +107,7 @@ export interface DataTableRowData {
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@@ -166,6 +171,10 @@ export class HaDataTable extends LitElement {
@query("slot[name='header']") private _header!: HTMLSlotElement;
@query(".mdc-data-table__header-row") private _headerRow?: HTMLDivElement;
@query("lit-virtualizer") private _scroller?: HTMLElement;
@state() private _collapsedGroups: string[] = [];
@state() private _lastSelectedRowId: string | null = null;
@@ -242,16 +251,28 @@ export class HaDataTable extends LitElement {
this.updateComplete.then(() => this._calcTableHeight());
}
protected updated() {
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
if (!header) {
protected updated(changedProps: PropertyValues) {
if (!this._headerRow) {
return;
}
if (header.scrollWidth > header.clientWidth) {
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
if (this._headerRow.scrollWidth > this._headerRow.clientWidth) {
this.style.setProperty(
"--table-row-width",
`${this._headerRow.scrollWidth}px`
);
} else {
this.style.removeProperty("--table-row-width");
}
if (
changedProps.has("selectable") ||
(!this.autoHeight &&
document.activeElement &&
AUTO_FOCUS_ALLOWED_ACTIVE_TAGS.includes(document.activeElement.tagName))
) {
this._focusScroller();
}
}
public willUpdate(properties: PropertyValues) {
@@ -517,6 +538,7 @@ export class HaDataTable extends LitElement {
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
.items=${this._groupData(
this._filteredData,
@@ -829,8 +851,10 @@ export class HaDataTable extends LitElement {
): Promise<DataTableRowData[]> => filterData(data, columns, filter)
);
private _handleHeaderClick(ev: Event) {
const columnId = (ev.currentTarget as any).columnId;
private _handleHeaderClick(
ev: HASSDomCurrentTargetEvent<HTMLElement & { columnId: string }>
) {
const columnId = ev.currentTarget.columnId;
if (!this.columns[columnId].sortable) {
return;
}
@@ -848,11 +872,12 @@ export class HaDataTable extends LitElement {
column: columnId,
direction: this.sortDirection,
});
this._focusScroller();
}
private _handleHeaderRowCheckboxClick(ev: Event) {
const checkbox = ev.target as HaCheckbox;
if (checkbox.checked) {
private _handleHeaderRowCheckboxClick(ev: HASSDomTargetEvent<HaCheckbox>) {
if (ev.target.checked) {
this.selectAll();
} else {
this._checkedRows = [];
@@ -861,9 +886,10 @@ export class HaDataTable extends LitElement {
this._lastSelectedRowId = null;
}
private _handleRowCheckboxClicked = (ev: Event) => {
const checkbox = ev.currentTarget as HaCheckbox;
const rowId = (checkbox as any).rowId;
private _handleRowCheckboxClicked = (
ev: HASSDomCurrentTargetEvent<HaCheckbox & { rowId: string }>
) => {
const rowId = ev.currentTarget.rowId;
const groupedData = this._groupData(
this._filteredData,
@@ -900,7 +926,7 @@ export class HaDataTable extends LitElement {
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
];
}
} else if (!checkbox.checked) {
} else if (!ev.currentTarget.checked) {
if (!this._checkedRows.includes(rowId)) {
this._checkedRows = [...this._checkedRows, rowId];
}
@@ -938,7 +964,9 @@ export class HaDataTable extends LitElement {
return checkedRows;
}
private _handleRowClick = (ev: Event) => {
private _handleRowClick = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { rowId: string }>
) => {
if (
ev
.composedPath()
@@ -954,14 +982,13 @@ export class HaDataTable extends LitElement {
) {
return;
}
const rowId = (ev.currentTarget as any).rowId;
const rowId = ev.currentTarget.rowId;
fireEvent(this, "row-click", { id: rowId }, { bubbles: false });
};
private _setTitle(ev: Event) {
const target = ev.currentTarget as HTMLElement;
if (target.scrollWidth > target.offsetWidth) {
target.setAttribute("title", target.innerText);
private _setTitle(ev: HASSDomCurrentTargetEvent<HTMLElement>) {
if (ev.currentTarget.scrollWidth > ev.currentTarget.offsetWidth) {
ev.currentTarget.setAttribute("title", ev.currentTarget.innerText);
}
}
@@ -983,6 +1010,12 @@ export class HaDataTable extends LitElement {
this._debounceSearch((ev.target as HTMLInputElement).value);
}
private _focusScroller(): void {
this._scroller?.focus({
preventScroll: true,
});
}
private async _calcTableHeight() {
if (this.autoHeight) {
return;
@@ -992,23 +1025,27 @@ export class HaDataTable extends LitElement {
}
@eventOptions({ passive: true })
private _saveScrollPos(e: Event) {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
private _saveScrollPos(e: HASSDomTargetEvent<HTMLDivElement>) {
this._savedScrollPos = e.target.scrollTop;
this.renderRoot.querySelector(".mdc-data-table__header-row")!.scrollLeft = (
e.target as HTMLDivElement
).scrollLeft;
if (this._headerRow) {
this._headerRow.scrollLeft = e.target.scrollLeft;
}
}
@eventOptions({ passive: true })
private _scrollContent(e: Event) {
this.renderRoot.querySelector("lit-virtualizer")!.scrollLeft = (
e.target as HTMLDivElement
).scrollLeft;
private _scrollContent(e: HASSDomTargetEvent<HTMLDivElement>) {
if (!this._scroller) {
return;
}
this._scroller.scrollLeft = e.target.scrollLeft;
}
private _collapseGroup = (ev: Event) => {
const groupName = (ev.currentTarget as any).group;
private _collapseGroup = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
) => {
const groupName = ev.currentTarget.group;
if (this._collapsedGroups.includes(groupName)) {
this._collapsedGroups = this._collapsedGroups.filter(
(grp) => grp !== groupName
@@ -1431,6 +1468,11 @@ export class HaDataTable extends LitElement {
contain: size layout !important;
overscroll-behavior: contain;
}
lit-virtualizer:focus,
lit-virtualizer:focus-visible {
outline: none;
}
`,
];
}
@@ -133,7 +133,8 @@ export class HaDateRangePicker extends LitElement {
${!this.minimal
? html`<ha-textarea
id="field"
mobile-multiline
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${(isThisYear(this.startDate)
@@ -336,14 +337,7 @@ export class HaDateRangePicker extends LitElement {
private _setTextareaFocusStyle(focused: boolean) {
const textarea = this.renderRoot.querySelector("ha-textarea");
if (textarea) {
const foundation = (textarea as any).mdcFoundation;
if (foundation) {
if (focused) {
foundation.activateFocus();
} else {
foundation.deactivateFocus();
}
}
textarea.setFocused(focused);
}
}
@@ -38,6 +38,8 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
private _getItems = memoizeOne(
(
hass: HomeAssistant,
@@ -124,7 +126,8 @@ export class HaEntityStatePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled || !this.entityId}
.disabled=${this.disabled ||
(!this.entityId && this.noEntity === false)}
.autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ??
+10 -1
View File
@@ -27,7 +27,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
* @cssprop --ha-button-height - The height of the button.
* @cssprop --ha-button-border-radius - The border radius of the button. defaults to `var(--ha-border-radius-pill)`.
*
* @attr {("small"|"medium")} size - Sets the button size.
* @attr {("small"|"medium"|"large")} size - Sets the button size.
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
@@ -62,6 +62,7 @@ export class HaButton extends Button {
transition: background-color var(--ha-animation-duration-fast)
ease-out;
text-wrap: wrap;
box-shadow: var(--ha-button-box-shadow);
}
:host([size="small"]) .button {
@@ -73,6 +74,14 @@ export class HaButton extends Button {
--wa-form-control-padding-inline: var(--ha-space-3);
}
:host([size="large"]) .button {
--wa-form-control-height: var(
--ha-button-height,
var(--button-height, 48px)
);
font-size: var(--ha-font-size-l);
}
:host([variant="brand"]) {
--button-color-fill-normal-active: var(
--ha-color-fill-primary-normal-active
-60
View File
@@ -1,60 +0,0 @@
import { FabBase } from "@material/mwc-fab/mwc-fab-base";
import { styles } from "@material/mwc-fab/mwc-fab.css";
import { customElement } from "lit/decorators";
import { css } from "lit";
import { mainWindow } from "../common/dom/get_main_window";
@customElement("ha-fab")
export class HaFab extends FabBase {
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.style.setProperty("--mdc-theme-secondary", "var(--primary-color)");
}
static override styles = [
styles,
css`
:host {
--mdc-typography-button-text-transform: none;
--mdc-typography-button-font-size: var(--ha-font-size-l);
--mdc-typography-button-font-family: var(--ha-font-family-body);
--mdc-typography-button-font-weight: var(--ha-font-weight-medium);
}
:host .mdc-fab--extended {
border-radius: var(
--ha-button-border-radius,
var(--ha-border-radius-pill)
);
}
:host .mdc-fab.mdc-fab--extended .ripple {
border-radius: var(
--ha-button-border-radius,
var(--ha-border-radius-pill)
);
}
:host .mdc-fab--extended .mdc-fab__icon {
margin-inline-start: -8px;
margin-inline-end: 12px;
direction: var(--direction);
}
:disabled {
--mdc-theme-secondary: var(--disabled-text-color);
cursor: not-allowed !important;
}
`,
// safari workaround - must be explicit
mainWindow.document.dir === "rtl"
? css`
:host .mdc-fab--extended .mdc-fab__icon {
direction: rtl;
}
`
: css``,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-fab": HaFab;
}
}
+6 -5
View File
@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiDelete, mdiFileUpload } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -12,6 +11,7 @@ import type { HomeAssistant } from "../types";
import { bytesToString } from "../util/bytes-to-string";
import "./ha-button";
import "./ha-icon-button";
import "./progress/ha-progress-bar";
declare global {
interface HASSDomEvents {
@@ -100,10 +100,11 @@ export class HaFileUpload extends LitElement {
</div>`
: nothing}
</div>
<mwc-linear-progress
<ha-progress-bar
.indeterminate=${!this.progress}
.progress=${this.progress ? this.progress / 100 : undefined}
></mwc-linear-progress>
.value=${this.progress}
loading
></ha-progress-bar>
</div>`
: html`<label
for=${this.value ? "" : "input"}
@@ -319,7 +320,7 @@ export class HaFileUpload extends LitElement {
--mdc-button-outline-color: var(--primary-color);
--ha-icon-button-size: 24px;
}
mwc-linear-progress {
ha-progress-bar {
width: 100%;
padding: 8px 32px;
box-sizing: border-box;
+1 -3
View File
@@ -100,9 +100,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
inputMode="numeric"
.label=${this.label}
.hint=${this.helper}
.value=${this.data !== undefined && this.data !== null
? this.data.toString()
: ""}
.value=${this.data?.toString() ?? ""}
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
+4 -4
View File
@@ -171,7 +171,7 @@ export class HaGauge extends LitElement {
? svg`
<path
class="needle"
d="M -36,-2 L -44,-1 A 1,1,0,0,0,-44,1 L -36,2 A 2,2,0,0,0,-36,-2 Z"
d="M -34,-3 L -48,-1 A 1,1,0,0,0,-48,1 L -34,3 A 2,2,0,0,0,-34,-3 Z"
style=${styleMap({ transform: `rotate(${this._angle}deg)` })}
/>
@@ -243,19 +243,19 @@ export class HaGauge extends LitElement {
.levels-base {
fill: none;
stroke: var(--primary-background-color);
stroke-width: 6;
stroke-width: 12;
stroke-linecap: butt;
}
.level {
fill: none;
stroke-width: 6;
stroke-width: 12;
stroke-linecap: butt;
}
.value {
fill: none;
stroke-width: 6;
stroke-width: 12;
stroke: var(--gauge-color);
stroke-linecap: butt;
transition: stroke-dashoffset 1s ease 0s;
-1
View File
@@ -1,7 +1,6 @@
import type { PropertyValues } from "lit";
import { ReactiveElement, render, html } from "lit";
import { customElement, property } from "lit/decorators";
// eslint-disable-next-line import/extensions
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event";
@@ -98,6 +98,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.noEntity=${this.selector.state?.no_entity ?? false}
.disabled=${this.disabled}
.required=${this.required}
allow-custom-value
@@ -65,15 +65,14 @@ export class HaTextSelector extends LitElement {
.label=${this.label}
.placeholder=${this.placeholder}
.value=${this.value || ""}
.helper=${this.helper}
helperPersistent
.hint=${this.helper}
.disabled=${this.disabled}
@input=${this._handleChange}
autocapitalize="none"
.autocomplete=${this.selector.text?.autocomplete}
spellcheck="false"
.required=${this.required}
autogrow
resize="auto"
></ha-textarea>`;
}
return html`<ha-input
+4 -1
View File
@@ -23,7 +23,10 @@ export class HaSlider extends Slider {
--marker-height: calc(var(--ha-slider-track-size, 4px) / 2);
--marker-width: calc(var(--ha-slider-track-size, 4px) / 2);
--wa-color-surface-default: var(--card-background-color);
--wa-color-neutral-fill-normal: var(--disabled-color);
--wa-color-neutral-fill-normal: var(
--ha-slider-track-color,
var(--disabled-color)
);
--wa-tooltip-background-color: var(
--ha-tooltip-background-color,
var(--secondary-background-color)
+18
View File
@@ -13,6 +13,24 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
connectedCallback(): void {
super.connectedCallback();
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
// which browsers use for back/forward navigation.
this.addEventListener("keydown", this._handleKeyDown, true);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown, true);
}
private _handleKeyDown = (event: KeyboardEvent) => {
if (event.altKey || event.metaKey) {
event.stopPropagation();
}
};
protected override handleClick(event: MouseEvent) {
if (this._dragScrollController.scrolled) {
return;
+231 -48
View File
@@ -1,66 +1,249 @@
import { TextAreaBase } from "@material/mwc-textarea/mwc-textarea-base";
import { styles as textfieldStyles } from "@material/mwc-textfield/mwc-textfield.css";
import { styles as textareaStyles } from "@material/mwc-textarea/mwc-textarea.css";
import type { PropertyValues } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import "@home-assistant/webawesome/dist/components/textarea/textarea";
import type WaTextarea from "@home-assistant/webawesome/dist/components/textarea/textarea";
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { WaInputMixin, waInputStyles } from "./input/wa-input-mixin";
/**
* Home Assistant textarea component
*
* @element ha-textarea
* @extends {LitElement}
*
* @summary
* A multi-line text input component supporting Home Assistant theming and validation, based on webawesome textarea.
*
* @slot label - Custom label content. Overrides the `label` property.
* @slot hint - Custom hint content. Overrides the `hint` property.
*
* @csspart wa-base - The underlying wa-textarea base wrapper.
* @csspart wa-hint - The underlying wa-textarea hint container.
* @csspart wa-textarea - The underlying wa-textarea textarea element.
*
* @cssprop --ha-textarea-padding-bottom - Padding below the textarea host.
* @cssprop --ha-textarea-max-height - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`.
* @cssprop --ha-textarea-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
*
* @attr {string} label - The textarea's label text.
* @attr {string} hint - The textarea's hint/helper text.
* @attr {string} placeholder - Placeholder text shown when the textarea is empty.
* @attr {boolean} readonly - Makes the textarea readonly.
* @attr {boolean} disabled - Disables the textarea and prevents user interaction.
* @attr {boolean} required - Makes the textarea a required field.
* @attr {number} rows - Number of visible text rows.
* @attr {number} minlength - Minimum number of characters required.
* @attr {number} maxlength - Maximum number of characters allowed.
* @attr {("none"|"vertical"|"horizontal"|"both"|"auto")} resize - Controls the textarea's resize behavior. Defaults to `"none"`.
* @attr {boolean} auto-validate - Validates the textarea on blur instead of on form submit.
* @attr {boolean} invalid - Marks the textarea as invalid.
* @attr {string} validation-message - Custom validation message shown when the textarea is invalid.
*/
@customElement("ha-textarea")
export class HaTextArea extends TextAreaBase {
@property({ type: Boolean, reflect: true }) autogrow = false;
export class HaTextArea extends WaInputMixin(LitElement) {
@property({ type: Number })
public rows?: number;
updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this.autogrow && changedProperties.has("value")) {
this.mdcRoot.dataset.value = this.value + '=\u200B"'; // add a zero-width space to correctly wrap
@property()
public resize: "none" | "vertical" | "horizontal" | "both" | "auto" = "none";
@query("wa-textarea")
private _textarea?: WaTextarea;
private readonly _hasSlotController = new HasSlotController(
this,
"label",
"hint"
);
protected get _formControl(): WaTextarea | undefined {
return this._textarea;
}
protected readonly _requiredMarkerCSSVar = "--ha-textarea-required-marker";
/** Programmatically toggle focus styling (used by ha-date-range-picker). */
public setFocused(focused: boolean): void {
if (focused) {
this.toggleAttribute("focused", true);
} else {
this.removeAttribute("focused");
}
}
static override styles = [
textfieldStyles,
textareaStyles,
protected render() {
const hasLabelSlot = this.label
? false
: this._hasSlotController.test("label");
const hasHintSlot = this.hint
? false
: this._hasSlotController.test("hint");
return html`
<wa-textarea
.value=${this.value ?? null}
.placeholder=${this.placeholder}
.readonly=${this.readonly}
.required=${this.required}
.rows=${this.rows ?? 4}
.resize=${this.resize}
.disabled=${this.disabled}
name=${ifDefined(this.name)}
autocapitalize=${ifDefined(this.autocapitalize || undefined)}
autocomplete=${ifDefined(this.autocomplete)}
.autofocus=${this.autofocus}
.spellcheck=${this.spellcheck}
inputmode=${ifDefined(this.inputmode || undefined)}
enterkeyhint=${ifDefined(this.enterkeyhint || undefined)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
class=${classMap({
input: true,
invalid: this.invalid || this._invalid,
"label-raised":
(this.value !== undefined && this.value !== "") ||
(this.label && this.placeholder),
"no-label": !this.label,
"hint-hidden":
!this.hint &&
!hasHintSlot &&
!this.required &&
!this._invalid &&
!this.invalid,
})}
@input=${this._handleInput}
@change=${this._handleChange}
@blur=${this._handleBlur}
@wa-invalid=${this._handleInvalid}
exportparts="base:wa-base, hint:wa-hint, textarea:wa-textarea"
>
${this.label || hasLabelSlot
? html`<slot name="label" slot="label"
>${this.label
? this._renderLabel(this.label, this.required)
: nothing}</slot
>`
: nothing}
<div
slot="hint"
class=${classMap({
error: this.invalid || this._invalid,
})}
role=${ifDefined(this.invalid || this._invalid ? "alert" : undefined)}
aria-live="polite"
>
${this._invalid || this.invalid
? this.validationMessage || this._textarea?.validationMessage
: this.hint ||
(hasHintSlot ? html`<slot name="hint"></slot>` : nothing)}
</div>
</wa-textarea>
`;
}
static styles = [
waInputStyles,
css`
:host {
--mdc-text-field-fill-color: var(--ha-color-form-background);
display: flex;
align-items: flex-start;
padding-bottom: var(--ha-textarea-padding-bottom);
}
:host([autogrow]) .mdc-text-field {
position: relative;
min-height: 74px;
min-width: 178px;
max-height: 200px;
/* Label styling */
wa-textarea::part(label) {
width: calc(100% - var(--ha-space-2));
background-color: var(--ha-color-form-background);
transition:
all var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
padding-inline-start: var(--ha-space-3);
padding-inline-end: var(--ha-space-3);
margin: var(--ha-space-1) var(--ha-space-1) 0;
padding-top: var(--ha-space-4);
white-space: nowrap;
overflow: hidden;
}
:host([autogrow]) .mdc-text-field:after {
content: attr(data-value);
margin-top: 23px;
margin-bottom: 9px;
line-height: var(--ha-line-height-normal);
min-height: 42px;
padding: 0px 32px 0 16px;
letter-spacing: var(
--mdc-typography-subtitle1-letter-spacing,
0.009375em
);
visibility: hidden;
white-space: pre-wrap;
:host(:focus-within) wa-textarea::part(label),
:host([focused]) wa-textarea::part(label) {
color: var(--primary-color);
}
:host([autogrow]) .mdc-text-field__input {
wa-textarea.label-raised::part(label),
:host(:focus-within) wa-textarea::part(label),
:host([focused]) wa-textarea::part(label) {
padding-top: var(--ha-space-2);
font-size: var(--ha-font-size-xs);
}
wa-textarea.no-label::part(label) {
height: 0;
padding: 0;
}
/* Base styling */
wa-textarea::part(base) {
min-height: 56px;
padding-top: var(--ha-space-6);
padding-bottom: var(--ha-space-2);
}
wa-textarea.no-label::part(base) {
padding-top: var(--ha-space-3);
}
wa-textarea::part(base)::after {
content: "";
position: absolute;
height: calc(100% - 32px);
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--ha-color-border-neutral-loud);
transition:
height var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
}
:host([autogrow]) .mdc-text-field.mdc-text-field--no-label:after {
margin-top: 16px;
margin-bottom: 16px;
:host(:focus-within) wa-textarea::part(base)::after,
:host([focused]) wa-textarea::part(base)::after {
height: 2px;
background-color: var(--primary-color);
}
.mdc-floating-label {
inset-inline-start: 16px !important;
inset-inline-end: initial !important;
transform-origin: var(--float-start) top;
:host(:focus-within) wa-textarea.invalid::part(base)::after,
wa-textarea.invalid:not([disabled])::part(base)::after {
background-color: var(--ha-color-border-danger-normal);
}
@media only screen and (min-width: 459px) {
:host([mobile-multiline]) .mdc-text-field__input {
white-space: nowrap;
max-height: 16px;
}
/* Textarea element styling */
wa-textarea::part(textarea) {
padding: 0 var(--ha-space-4);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
}
:host([resize="auto"]) wa-textarea::part(textarea) {
max-height: var(--ha-textarea-max-height, 200px);
overflow-y: auto;
}
wa-textarea:hover::part(base),
wa-textarea:hover::part(label) {
background-color: var(--ha-color-form-background-hover);
}
wa-textarea[disabled]::part(textarea) {
cursor: not-allowed;
}
wa-textarea[disabled]::part(base),
wa-textarea[disabled]::part(label) {
background-color: var(--ha-color-form-background-disabled);
}
`,
];
+22 -2
View File
@@ -26,6 +26,8 @@ export interface ToastClosedEventDetail {
export class HaToast extends LitElement {
@property({ attribute: "label-text" }) public labelText = "";
@property({ attribute: "announce-text" }) public announceText?: string;
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@query(".toast")
@@ -186,8 +188,6 @@ export class HaToast extends LitElement {
active: this._active,
visible: this._visible,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
@@ -196,6 +196,14 @@ export class HaToast extends LitElement {
<slot name="dismiss"></slot>
</div>
</div>
<span
class="assistive-message"
role="status"
aria-live=${this._active ? "polite" : "off"}
aria-atomic="true"
>
${this.announceText ?? this.labelText}
</span>
`;
}
@@ -246,6 +254,18 @@ export class HaToast extends LitElement {
min-width: 0;
}
.assistive-message {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.actions {
display: flex;
align-items: center;
@@ -5,6 +5,7 @@ import {
import { supportsPassiveEventListener } from "@material/mwc-base/utils";
import type { MDCTopAppBarAdapter } from "@material/top-app-bar/adapter";
import { strings } from "@material/top-app-bar/constants";
// eslint-disable-next-line import-x/no-named-as-default
import MDCFixedTopAppBarFoundation from "@material/top-app-bar/fixed/foundation";
import { html, css, nothing } from "lit";
import { property, query, customElement } from "lit/decorators";
+119 -359
View File
@@ -11,15 +11,14 @@ import {
html,
nothing,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { stopPropagation } from "../../common/dom/stop_propagation";
import "../ha-icon-button";
import "../ha-svg-icon";
import "../ha-tooltip";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
import { WaInputMixin, waInputStyles } from "./wa-input-mixin";
export type InputType =
| "date"
@@ -78,35 +77,16 @@ export type InputType =
* @attr {string} validation-message - Custom validation message shown when the input is invalid.
*/
@customElement("ha-input")
export class HaInput extends LitElement {
export class HaInput extends WaInputMixin(LitElement) {
@property({ reflect: true }) appearance: "material" | "outlined" = "material";
@property({ reflect: true })
public type: InputType = "text";
@property()
public value?: string;
/** The input's label. */
@property()
public label = "";
/** The input's hint. */
@property()
public hint? = "";
/** Adds a clear button when the input is not empty. */
@property({ type: Boolean, attribute: "with-clear" })
public withClear = false;
/** Placeholder text to show as a hint when the input is empty. */
@property()
public placeholder = "";
/** Makes the input readonly. */
@property({ type: Boolean })
public readonly = false;
/** Adds a button to toggle the password's visibility. */
@property({ type: Boolean, attribute: "password-toggle" })
public passwordToggle = false;
@@ -119,22 +99,10 @@ export class HaInput extends LitElement {
@property({ type: Boolean, attribute: "without-spin-buttons" })
public withoutSpinButtons = false;
/** Makes the input a required field. */
@property({ type: Boolean, reflect: true })
public required = false;
/** A regular expression pattern to validate input against. */
@property()
public pattern?: string;
/** The minimum length of input that will be considered valid. */
@property({ type: Number })
public minlength?: number;
/** The maximum length of input that will be considered valid. */
@property({ type: Number })
public maxlength?: number;
/** The input's minimum value. Only applies to date and number input types. */
@property()
public min?: number | string;
@@ -147,88 +115,13 @@ export class HaInput extends LitElement {
@property()
public step?: number | "any";
/** Controls whether and how text input is automatically capitalized. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public autocapitalize:
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters"
| "" = "";
/** Indicates whether the browser's autocorrect feature is on or off. */
@property({ type: Boolean })
public autocorrect = false;
/** Specifies what permission the browser has to provide assistance in filling out form field values. */
@property()
public autocomplete?: string;
/** Indicates that the input should receive focus on page load. */
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public autofocus = false;
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public enterkeyhint:
| "enter"
| "done"
| "go"
| "next"
| "previous"
| "search"
| "send"
| "" = "";
/** Enables spell checking on the input. */
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public spellcheck = true;
/** Tells the browser what type of data will be entered by the user. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public inputmode:
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "" = "";
/** The name of the input, submitted as a name/value pair with form data. */
@property()
public name?: string;
/** Disables the form control. */
@property({ type: Boolean })
public disabled = false;
/** Custom validation message to show when the input is invalid. */
@property({ attribute: "validation-message" })
public validationMessage? = "";
/** When true, validates the input on blur instead of on form submit. */
@property({ type: Boolean, attribute: "auto-validate" })
public autoValidate = false;
@property({ type: Boolean })
public invalid = false;
@property({ type: Boolean, attribute: "inset-label" })
public insetLabel = false;
@state()
private _invalid = false;
@query("wa-input")
private _input?: WaInput;
@@ -240,37 +133,8 @@ export class HaInput extends LitElement {
"start"
);
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
/** Selects all the text in the input. */
public select(): void {
this._input?.select();
}
/** Sets the start and end positions of the text selection (0-based). */
public setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection?: "forward" | "backward" | "none"
): void {
this._input?.setSelectionRange(
selectionStart,
selectionEnd,
selectionDirection
);
}
/** Replaces a range of text with a new string. */
public setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void {
this._input?.setRangeText(replacement, start, end, selectMode);
protected get _formControl(): WaInput | undefined {
return this._input;
}
/** Displays the browser picker for an input element. */
@@ -288,19 +152,6 @@ export class HaInput extends LitElement {
this._input?.stepDown();
}
public checkValidity(): boolean {
return nativeElementInternalsSupported
? (this._input?.checkValidity() ?? true)
: true;
}
public reportValidity(): boolean {
const valid = this.checkValidity();
this._invalid = !valid;
return valid;
}
protected override async firstUpdated(
changedProperties: PropertyValues
): Promise<void> {
@@ -351,6 +202,7 @@ export class HaInput extends LitElement {
.name=${this.name}
.disabled=${this.disabled}
class=${classMap({
input: true,
invalid: this.invalid || this._invalid,
"label-raised":
(this.value !== undefined && this.value !== "") ||
@@ -372,7 +224,9 @@ export class HaInput extends LitElement {
>
${this.label || hasLabelSlot
? html`<slot name="label" slot="label"
>${this._renderLabel(this.label, this.required)}</slot
>${this.label
? this._renderLabel(this.label, this.required)
: nothing}</slot
>`
: nothing}
<slot name="start" slot="start" @slotchange=${this._syncStartSlotWidth}>
@@ -419,27 +273,6 @@ export class HaInput extends LitElement {
return nothing;
}
private _handleInput() {
this.value = this._input?.value ?? undefined;
if (this._invalid && this._input?.checkValidity()) {
this._invalid = false;
}
}
private _handleChange() {
this.value = this._input?.value ?? undefined;
}
private _handleBlur() {
if (this.autoValidate) {
this._invalid = !this._input?.checkValidity();
}
}
private _handleInvalid() {
this._invalid = true;
}
private _syncStartSlotWidth = () => {
const startEl = this._input?.shadowRoot?.querySelector(
'[part~="start"]'
@@ -460,206 +293,133 @@ export class HaInput extends LitElement {
}
};
private _renderLabel = memoizeOne((label: string, required: boolean) => {
if (!required) {
return label;
}
static styles = [
waInputStyles,
css`
:host {
display: flex;
align-items: flex-start;
padding-top: var(--ha-input-padding-top);
padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2));
text-align: var(--ha-input-text-align, start);
}
:host([appearance="outlined"]) {
padding-bottom: var(--ha-input-padding-bottom);
}
let marker = getComputedStyle(this).getPropertyValue(
"--ha-input-required-marker"
);
wa-input::part(label) {
padding-inline-start: calc(
var(--start-slot-width, 0px) + var(--ha-space-4)
);
padding-inline-end: var(--ha-space-4);
padding-top: var(--ha-space-5);
}
if (!marker) {
marker = "*";
}
:host([appearance="material"]:focus-within) wa-input::part(label) {
color: var(--primary-color);
}
if (marker.startsWith('"') && marker.endsWith('"')) {
marker = marker.slice(1, -1);
}
wa-input.label-raised::part(label),
:host(:focus-within) wa-input::part(label),
:host([type="date"]) wa-input::part(label) {
padding-top: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
}
if (!marker) {
return label;
}
wa-input::part(base) {
height: 56px;
padding: 0 var(--ha-space-4);
}
return `${label}${marker}`;
});
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: 32px;
padding: 0 var(--ha-space-2);
}
static styles = css`
:host {
display: flex;
align-items: flex-start;
padding-top: var(--ha-input-padding-top);
padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2));
text-align: var(--ha-input-text-align, start);
}
:host([appearance="outlined"]) {
padding-bottom: var(--ha-input-padding-bottom);
}
wa-input {
flex: 1;
min-width: 0;
--wa-transition-fast: var(--wa-transition-normal);
position: relative;
}
:host([appearance="outlined"]) wa-input::part(base) {
border: 1px solid var(--ha-color-border-neutral-quiet);
background-color: var(--card-background-color);
border-radius: var(--ha-border-radius-md);
transition: border-color var(--wa-transition-normal) ease-in-out;
}
wa-input::part(label) {
position: absolute;
top: 0;
font-weight: var(--ha-font-weight-normal);
font-family: var(--ha-font-family-body);
transition: all var(--wa-transition-normal) ease-in-out;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-condensed);
z-index: 1;
pointer-events: none;
padding-inline-start: calc(
var(--start-slot-width, 0px) + var(--ha-space-4)
);
padding-inline-end: var(--ha-space-4);
padding-top: var(--ha-space-5);
font-size: var(--ha-font-size-m);
}
:host([appearance="material"]) ::part(base)::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--ha-color-border-neutral-loud);
transition:
height var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
}
:host([appearance="material"]:focus-within) wa-input::part(label) {
color: var(--primary-color);
}
:host([appearance="material"]:focus-within) wa-input::part(base)::after {
height: 2px;
background-color: var(--primary-color);
}
:host(:focus-within) wa-input.invalid::part(label),
wa-input.invalid:not([disabled])::part(label) {
color: var(--ha-color-fill-danger-loud-resting);
}
:host([appearance="material"]:focus-within)
wa-input.invalid::part(base)::after,
:host([appearance="material"])
wa-input.invalid:not([disabled])::part(base)::after {
background-color: var(--ha-color-border-danger-normal);
}
wa-input.label-raised::part(label),
:host(:focus-within) wa-input::part(label),
:host([type="date"]) wa-input::part(label) {
padding-top: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
}
wa-input::part(input) {
padding-top: var(--ha-space-3);
padding-inline-start: var(--input-padding-inline-start, 0);
}
wa-input::part(base) {
height: 56px;
background-color: var(--ha-color-form-background);
border-top-left-radius: var(--ha-border-radius-sm);
border-top-right-radius: var(--ha-border-radius-sm);
border-bottom-left-radius: var(--ha-border-radius-square);
border-bottom-right-radius: var(--ha-border-radius-square);
border: none;
padding: 0 var(--ha-space-4);
position: relative;
transition: background-color var(--wa-transition-normal) ease-in-out;
}
wa-input.no-label::part(input) {
padding-top: 0;
}
:host([type="color"]) wa-input::part(input) {
padding-top: var(--ha-space-6);
padding-bottom: 2px;
cursor: pointer;
}
:host([type="color"]) wa-input.no-label::part(input) {
padding: var(--ha-space-2);
}
:host([type="color"]) wa-input.no-label::part(base) {
padding: 0;
}
wa-input::part(input)::placeholder {
color: var(--ha-color-neutral-60);
}
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: 32px;
padding: 0 var(--ha-space-2);
}
wa-input::part(base):hover {
background-color: var(--ha-color-form-background-hover);
}
:host([appearance="outlined"]) wa-input::part(base) {
border: 1px solid var(--ha-color-border-neutral-quiet);
background-color: var(--card-background-color);
border-radius: var(--ha-border-radius-md);
transition: border-color var(--wa-transition-normal) ease-in-out;
}
:host([appearance="outlined"]) wa-input::part(base):hover {
border-color: var(--ha-color-border-neutral-normal);
}
:host([appearance="outlined"]:focus-within) wa-input::part(base) {
border-color: var(--primary-color);
}
:host([appearance="material"]) ::part(base)::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--ha-color-border-neutral-loud);
transition:
height var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
}
wa-input:disabled::part(base) {
background-color: var(--ha-color-form-background-disabled);
}
:host([appearance="material"]:focus-within) wa-input::part(base)::after {
height: 2px;
background-color: var(--primary-color);
}
wa-input:disabled::part(label) {
opacity: 0.5;
}
:host([appearance="material"]:focus-within)
wa-input.invalid::part(base)::after,
:host([appearance="material"])
wa-input.invalid:not([disabled])::part(base)::after {
background-color: var(--ha-color-border-danger-normal);
}
wa-input::part(end) {
color: var(--ha-color-text-secondary);
}
wa-input::part(input) {
padding-top: var(--ha-space-3);
padding-inline-start: var(--input-padding-inline-start, 0);
}
wa-input.no-label::part(input) {
padding-top: 0;
}
:host([type="color"]) wa-input::part(input) {
padding-top: var(--ha-space-6);
padding-bottom: 2px;
cursor: pointer;
}
:host([type="color"]) wa-input.no-label::part(input) {
padding: var(--ha-space-2);
}
:host([type="color"]) wa-input.no-label::part(base) {
padding: 0;
}
wa-input::part(input)::placeholder {
color: var(--ha-color-neutral-60);
}
:host(:focus-within) wa-input::part(base) {
outline: none;
}
wa-input::part(base):hover {
background-color: var(--ha-color-form-background-hover);
}
:host([appearance="outlined"]) wa-input::part(base):hover {
border-color: var(--ha-color-border-neutral-normal);
}
:host([appearance="outlined"]:focus-within) wa-input::part(base) {
border-color: var(--primary-color);
}
wa-input:disabled::part(base) {
background-color: var(--ha-color-form-background-disabled);
}
wa-input:disabled::part(label) {
opacity: 0.5;
}
wa-input::part(hint) {
min-height: var(--ha-space-5);
margin-block-start: 0;
margin-inline-start: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
display: flex;
align-items: center;
color: var(--ha-color-text-secondary);
}
wa-input.hint-hidden::part(hint) {
height: 0;
min-height: 0;
}
.error {
color: var(--ha-color-on-danger-quiet);
}
wa-input::part(end) {
color: var(--ha-color-text-secondary);
}
:host([appearance="outlined"]) wa-input.no-label {
--ha-icon-button-size: 24px;
--mdc-icon-size: 18px;
}
`;
:host([appearance="outlined"]) wa-input.no-label {
--ha-icon-button-size: 24px;
--mdc-icon-size: 18px;
}
`,
];
}
declare global {
+357
View File
@@ -0,0 +1,357 @@
import { type LitElement, css } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { Constructor } from "../../types";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
/**
* Minimal interface for the inner wa-input / wa-textarea element.
*/
export interface WaInput {
value: string | null;
select(): void;
setSelectionRange(
start: number,
end: number,
direction?: "forward" | "backward" | "none"
): void;
setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void;
checkValidity(): boolean;
validationMessage: string;
}
export interface WaInputMixinInterface {
value?: string;
label: string;
hint?: string;
placeholder: string;
readonly: boolean;
required: boolean;
minlength?: number;
maxlength?: number;
autocapitalize:
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters"
| "";
autocomplete?: string;
autofocus: boolean;
spellcheck: boolean;
inputmode:
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "";
enterkeyhint:
| "enter"
| "done"
| "go"
| "next"
| "previous"
| "search"
| "send"
| "";
name?: string;
disabled: boolean;
validationMessage?: string;
autoValidate: boolean;
invalid: boolean;
select(): void;
setSelectionRange(
start: number,
end: number,
direction?: "forward" | "backward" | "none"
): void;
setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void;
checkValidity(): boolean;
reportValidity(): boolean;
}
export const WaInputMixin = <T extends Constructor<LitElement>>(
superClass: T
) => {
class FormControlMixinClass extends superClass {
@property()
public value?: string;
@property()
public label? = "";
@property()
public hint? = "";
@property()
public placeholder? = "";
@property({ type: Boolean })
public readonly = false;
@property({ type: Boolean, reflect: true })
public required = false;
@property({ type: Number })
public minlength?: number;
@property({ type: Number })
public maxlength?: number;
@property()
// eslint-disable-next-line lit/no-native-attributes
public autocapitalize:
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters"
| "" = "";
@property()
public autocomplete?: string;
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public autofocus = false;
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public spellcheck = true;
@property()
// eslint-disable-next-line lit/no-native-attributes
public inputmode:
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "" = "";
@property()
// eslint-disable-next-line lit/no-native-attributes
public enterkeyhint:
| "enter"
| "done"
| "go"
| "next"
| "previous"
| "search"
| "send"
| "" = "";
@property()
public name?: string;
@property({ type: Boolean })
public disabled = false;
@property({ attribute: "validation-message" })
public validationMessage? = "";
@property({ type: Boolean, attribute: "auto-validate" })
public autoValidate = false;
@property({ type: Boolean })
public invalid = false;
@state()
protected _invalid = false;
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
/**
* Override in subclass to return the inner wa-input / wa-textarea element.
*/
protected get _formControl(): WaInput | undefined {
throw new Error("_formControl getter must be implemented by subclass");
}
/**
* Override in subclass to set the CSS custom property name
* used for the required-marker character (e.g. "--ha-input-required-marker").
*/
protected readonly _requiredMarkerCSSVar: string =
"--ha-input-required-marker";
public select(): void {
this._formControl?.select();
}
public setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection?: "forward" | "backward" | "none"
): void {
this._formControl?.setSelectionRange(
selectionStart,
selectionEnd,
selectionDirection
);
}
public setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void {
this._formControl?.setRangeText(replacement, start, end, selectMode);
}
public checkValidity(): boolean {
return nativeElementInternalsSupported
? (this._formControl?.checkValidity() ?? true)
: true;
}
public reportValidity(): boolean {
const valid = this.checkValidity();
this._invalid = !valid;
return valid;
}
protected _handleInput(): void {
this.value = this._formControl?.value ?? undefined;
if (this._invalid && this._formControl?.checkValidity()) {
this._invalid = false;
}
}
protected _handleChange(): void {
this.value = this._formControl?.value ?? undefined;
}
protected _handleBlur(): void {
if (this.autoValidate) {
this._invalid = !this._formControl?.checkValidity();
}
}
protected _handleInvalid(): void {
this._invalid = true;
}
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
if (!required) {
return label;
}
let marker = getComputedStyle(this).getPropertyValue(
this._requiredMarkerCSSVar
);
if (!marker) {
marker = "*";
}
if (marker.startsWith('"') && marker.endsWith('"')) {
marker = marker.slice(1, -1);
}
if (!marker) {
return label;
}
return `${label}${marker}`;
});
}
return FormControlMixinClass;
};
/**
* Shared styles for form controls (ha-input / ha-textarea).
* Both components add the `control` CSS class to the inner wa-input / wa-textarea
* element so these rules can target them with a single selector.
*/
export const waInputStyles = css`
/* Inner element reset */
.input {
flex: 1;
min-width: 0;
--wa-transition-fast: var(--wa-transition-normal);
position: relative;
}
/* Label base */
.input::part(label) {
position: absolute;
top: 0;
font-weight: var(--ha-font-weight-normal);
font-family: var(--ha-font-family-body);
transition: all var(--wa-transition-normal) ease-in-out;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-condensed);
z-index: 1;
pointer-events: none;
font-size: var(--ha-font-size-m);
}
/* Invalid label */
:host(:focus-within) .input.invalid::part(label),
.input.invalid:not([disabled])::part(label) {
color: var(--ha-color-fill-danger-loud-resting);
}
/* Base common */
.input::part(base) {
background-color: var(--ha-color-form-background);
border-top-left-radius: var(--ha-border-radius-sm);
border-top-right-radius: var(--ha-border-radius-sm);
border-bottom-left-radius: var(--ha-border-radius-square);
border-bottom-right-radius: var(--ha-border-radius-square);
border: none;
position: relative;
transition: background-color var(--wa-transition-normal) ease-in-out;
}
/* Focus outline removal */
:host(:focus-within) .input::part(base) {
outline: none;
}
/* Hint */
.input::part(hint) {
min-height: var(--ha-space-5);
margin-block-start: 0;
margin-inline-start: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
display: flex;
align-items: center;
color: var(--ha-color-text-secondary);
}
.input.hint-hidden::part(hint) {
height: 0;
min-height: 0;
}
/* Error hint text */
.error {
color: var(--ha-color-on-danger-quiet);
}
`;
+5 -1
View File
@@ -254,7 +254,11 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map);
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
latitude: this.hass?.config.latitude ?? 52.3731339,
longitude: this.hass?.config.longitude ?? 4.8903147,
zoom: this.zoom,
});
this._updateMapStyle();
this.leafletMap.on("click", (ev) => {
if (this._clickCount === 0) {
@@ -79,6 +79,7 @@ class DialogMediaPlayerBrowse extends LitElement {
<ha-dialog
.hass=${this.hass}
.open=${this._open}
width="large"
flexcontent
@closed=${this.closeDialog}
@opened=${this._dialogOpened}
@@ -230,6 +231,8 @@ class DialogMediaPlayerBrowse extends LitElement {
--media-browser-max-height: calc(
100vh - 65px - var(--safe-area-inset-y)
);
height: 100vh;
height: 100dvh;
}
:host(.opened) ha-media-player-browse {
@@ -248,7 +251,6 @@ class DialogMediaPlayerBrowse extends LitElement {
--media-browser-max-height: calc(
100vh - 145px - var(--safe-area-inset-y)
);
width: 700px;
}
}
@@ -58,7 +58,7 @@ class BrowseMediaTTS extends LitElement {
<ha-card>
<div class="card-content">
<ha-textarea
autogrow
resize="auto"
.label=${this.hass.localize(
"ui.components.media-browser.tts.message"
)}
@@ -200,7 +200,7 @@ class BrowseMediaTTS extends LitElement {
}
private async _ttsClicked(): Promise<void> {
const message = this.shadowRoot!.querySelector("ha-textarea")!.value;
const message = this.shadowRoot!.querySelector("ha-textarea")!.value ?? "";
this._message = message;
const item = { ...this.item };
const query = new URLSearchParams();
@@ -49,7 +49,6 @@ import "../entity/ha-entity-picker";
import "../ha-alert";
import "../ha-button";
import "../ha-card";
import "../ha-fab";
import "../ha-icon-button";
import "../ha-list";
import "../ha-list-item";
@@ -446,24 +445,20 @@ export class HaMediaPlayerBrowse extends LitElement {
currentItem.media_content_id
))
? html`
<ha-fab
mini
<ha-button
class="fab"
.item=${currentItem}
@click=${this._actionClicked}
.title=${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
>
<ha-svg-icon
slot="icon"
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</ha-fab>
</ha-button>
`
: ""}
</div>
@@ -1363,11 +1358,11 @@ export class HaMediaPlayerBrowse extends LitElement {
height 0.4s,
padding-bottom 0.4s;
}
ha-fab {
.fab {
position: absolute;
--mdc-theme-secondary: var(--primary-color);
bottom: -20px;
right: 20px;
bottom: calc(var(--ha-space-5) * -1);
right: var(--ha-space-5);
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
:host([narrow]) .header-info ha-button {
margin-top: 16px;
@@ -1429,11 +1424,10 @@ export class HaMediaPlayerBrowse extends LitElement {
padding-bottom: initial;
margin-bottom: 0;
}
:host([scrolled]) ha-fab {
bottom: 0px;
right: -24px;
--mdc-fab-box-shadow: none;
--mdc-theme-secondary: rgba(var(--rgb-primary-color), 0.5);
:host([scrolled]) .fab {
bottom: 0;
right: calc(var(--ha-space-6) * -1);
--ha-button-box-shadow: none;
}
lit-virtualizer {
@@ -0,0 +1,92 @@
import ProgressBar from "@home-assistant/webawesome/dist/components/progress-bar/progress-bar";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
/**
* Home Assistant progress bar component
*
* @element ha-progress-bar
* @extends {ProgressBar}
*
* @summary
* A stylable progress bar component based on webawesome progress bar.
* Supports regular, indeterminate, and loading states with Home Assistant theming.
*
* @cssprop --ha-progress-bar-indicator-color - Color of the filled progress indicator.
* @cssprop --ha-progress-bar-track-color - Color of the progress track.
* @cssprop --ha-progress-bar-track-height - Height of the progress track. Defaults to `16px`.
* @cssprop --ha-progress-bar-border-radius - Border radius of the progress bar. Defaults to `var(--ha-border-radius-pill)`.
* @cssprop --ha-progress-bar-animation-duration - Animation duration for indeterminate/loading highlight. Defaults to `2.5s`.
*
* @attr {boolean} loading - Shows the loading highlight animation on top of the indicator.
* @attr {boolean} indeterminate - Shows indeterminate progress animation (inherited from ProgressBar).
*/
@customElement("ha-progress-bar")
export class HaProgressBar extends ProgressBar {
@property({ type: Boolean, reflect: true })
loading = false;
static get styles(): CSSResultGroup {
return [
ProgressBar.styles,
css`
:host {
--indicator-color: var(
--ha-progress-bar-indicator-color,
var(--ha-color-on-primary-normal)
);
--track-color: var(
--ha-progress-bar-track-color,
var(--ha-color-fill-neutral-normal-hover)
);
--track-height: var(--ha-progress-bar-track-height, 16px);
--wa-transition-slow: var(--ha-animation-duration-slow);
}
.progress-bar {
border-radius: var(
--ha-progress-bar-border-radius,
var(--ha-border-radius-pill)
);
}
@keyframes slide-highlight {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
:host([indeterminate]) .indicator {
animation: wa-progress-indeterminate
var(--ha-progress-bar-animation-duration, 2.5s) infinite
cubic-bezier(0.37, 0, 0.63, 1);
}
:host([indeterminate]) .indicator::after,
:host([loading]) .indicator::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
var(--ha-color-fill-primary-normal-hover) 45%,
var(--ha-color-fill-primary-normal-active) 50%,
var(--ha-color-fill-primary-normal-hover) 55%,
transparent 100%
);
opacity: 0.4;
animation: slide-highlight
var(--ha-progress-bar-animation-duration, 2.5s) infinite
cubic-bezier(0.37, 0, 0.63, 1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-progress-bar": HaProgressBar;
}
}
+28 -3
View File
@@ -1,3 +1,4 @@
import { TZDate } from "@date-fns/tz";
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import {
@@ -68,8 +69,22 @@ const localizeTimeString = (
return time;
}
try {
const dt = new Date("1970-01-01T" + time);
if (chunks.length === 2 || Number(chunks[2]) === 0) {
const hours = Number(chunks[0]);
const minutes = Number(chunks[1]);
const seconds = chunks.length > 2 ? Number(chunks[2]) : 0;
// Create date in the server timezone so formatTime converts correctly
// when the user's browser timezone differs from the HA server timezone.
const now = new Date();
const dt = new TZDate(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes,
seconds,
config.time_zone
);
if (chunks.length === 2 || seconds === 0) {
return formatTime(dt, locale, config);
}
return formatTimeWithSeconds(dt, locale, config);
@@ -1197,7 +1212,17 @@ const describeLegacyCondition = (
let hasTime = "";
if (after !== undefined && before !== undefined) {
hasTime = "after_before";
if (
typeof condition.after === "string" &&
!condition.after.includes(".") &&
typeof condition.before === "string" &&
!condition.before.includes(".") &&
condition.after > condition.before
) {
hasTime = "after_before_or";
} else {
hasTime = "after_before";
}
} else if (after !== undefined) {
hasTime = "after";
} else if (before !== undefined) {
+95 -44
View File
@@ -60,6 +60,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,
@@ -72,11 +75,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}`
)
@@ -84,7 +87,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;
@@ -94,54 +97,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,
location: ev.location,
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,
element: Element,
@@ -220,3 +185,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,
};
};
+1
View File
@@ -4,6 +4,7 @@ export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
}
export interface SidebarFrontendUserData {
-4
View File
@@ -105,10 +105,6 @@ const generateNavigationConfigSectionCommands = (
hass: HomeAssistant,
filterOptions: NavigationFilterOptions = {}
): BaseNavigationCommand[] => {
if (!hass.user?.is_admin) {
return [];
}
const items: NavigationInfo[] = [];
const allPages = Object.values(configSections).flat();
const visiblePages = filterNavigationPages(hass, allPages, filterOptions);
+1
View File
@@ -458,6 +458,7 @@ export interface StateSelector {
attribute?: string;
hide_states?: string[];
multiple?: boolean;
no_entity?: boolean;
} | null;
}
@@ -516,7 +516,7 @@ class DataEntryFlowDialog extends LitElement {
}
} else if (_step.next_flow[0] === "config_subentries_flow") {
if (_step.type === "create_entry") {
showSubConfigFlowDialog(this, _step.result!, _step.next_flow[0], {
showSubConfigFlowDialog(this, _step.result!, "", {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
@@ -39,6 +39,7 @@ export const showSubConfigFlowDialog = (
},
fetchFlow: async (hass, flowId) => {
const step = await fetchSubConfigFlow(hass, flowId);
flowType = (step.handler as unknown as [string, string])[1];
await hass.loadFragmentTranslation("config");
await hass.loadBackendTranslation(
"config_subentries",
@@ -2,8 +2,8 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { blankBeforePercent } from "../../common/translations/blank_before_percent";
import "../../components/ha-progress-ring";
import "../../components/ha-spinner";
import "../../components/progress/ha-progress-ring";
import type { DataEntryFlowStepProgress } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
+1 -1
View File
@@ -87,7 +87,7 @@ export class DialogEnterCode
private _numberClick(e: MouseEvent): void {
const val = (e.currentTarget! as any).value;
this._input!.value = this._input!.value + val;
this._input!.value = (this._input!.value ?? "") + val;
this._showClearButton = true;
}
+4 -1
View File
@@ -177,7 +177,10 @@ export const showDialog = async (
throw new Error("Unknown dialog type loaded");
}
(parentElement || element).shadowRoot!.appendChild(dialogElement!);
const targetParent = (parentElement || element).shadowRoot!;
if (dialogElement!.parentNode !== targetParent) {
targetParent.appendChild(dialogElement!);
}
return true;
};
@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@@ -15,6 +14,7 @@ import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import "../../../components/ha-switch";
import "../../../components/progress/ha-progress-bar";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import { isUnavailableState } from "../../../data/entity/entity";
@@ -191,11 +191,11 @@ class MoreInfoUpdate extends LitElement {
${this.stateObj.attributes.in_progress
? supportsFeature(this.stateObj, UpdateEntityFeature.PROGRESS) &&
this.stateObj.attributes.update_percentage !== null
? html`<mwc-linear-progress
.progress=${this.stateObj.attributes.update_percentage / 100}
buffer=""
></mwc-linear-progress>`
: html`<mwc-linear-progress indeterminate></mwc-linear-progress>`
? html`<ha-progress-bar
loading
.value=${this.stateObj.attributes.update_percentage}
></ha-progress-bar>`
: html`<ha-progress-bar indeterminate></ha-progress-bar>`
: nothing}
<h3>${this.stateObj.attributes.title}</h3>
${this._error
@@ -521,10 +521,6 @@ class MoreInfoUpdate extends LitElement {
justify-content: center;
align-items: center;
}
mwc-linear-progress {
margin-bottom: calc(var(--ha-space-2) * -1);
margin-top: var(--ha-space-1);
}
ha-markdown {
direction: ltr;
padding-bottom: var(--ha-space-4);
+10 -4
View File
@@ -57,7 +57,11 @@ import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import type { QuickBarParams, QuickBarSection } from "./show-dialog-quick-bar";
import {
effectiveQuickBarMode,
type QuickBarParams,
type QuickBarSection,
} from "./show-dialog-quick-bar";
const SEPARATOR = "________";
@@ -100,7 +104,7 @@ export class QuickBar extends LitElement {
this._translationsLoaded = true;
}
this._initialize();
this._selectedSection = params.mode;
this._selectedSection = effectiveQuickBarMode(this.hass.user, params.mode);
this._showHint = params.showHint ?? false;
this._relatedResult = params.contextItem ? params.related : undefined;
@@ -656,8 +660,10 @@ export class QuickBar extends LitElement {
private _generateActionCommandsMemoized = memoizeOne(generateActionCommands);
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
private _createFuseIndex = (
states: PickerComboBoxItem[],
keys: FuseWeightedKey[]
) => Fuse.createIndex(keys, states);
private _fuseIndexes = {
entity: memoizeOne((states: PickerComboBoxItem[]) =>
@@ -1,5 +1,6 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { ItemType, RelatedResult } from "../../data/search";
import type { HomeAssistant } from "../../types";
import { closeDialog } from "../make-dialog-manager";
export type QuickBarSection =
@@ -22,6 +23,20 @@ export interface QuickBarParams {
related?: RelatedResult;
}
/** Non-admin users cannot scope the bar to command, device, or area (those sections are admin-only). */
export const effectiveQuickBarMode = (
user: HomeAssistant["user"],
mode?: QuickBarSection
): QuickBarSection | undefined => {
if (mode && user?.is_admin) {
return mode;
}
if (mode === "command" || mode === "device" || mode === "area") {
return undefined;
}
return mode;
};
export const loadQuickBar = () => import("./ha-quick-bar");
export const showQuickBar = (
+4 -5
View File
@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import {
mdiAutoFix,
mdiLifebuoy,
@@ -19,6 +18,7 @@ import "../../components/ha-icon-next";
import "../../components/ha-md-list";
import "../../components/ha-md-list-item";
import "../../components/ha-spinner";
import "../../components/progress/ha-progress-bar";
import { fetchBackupInfo } from "../../data/backup";
import type { BackupManagerState } from "../../data/backup_manager";
import {
@@ -120,9 +120,7 @@ class DialogRestart extends LitElement {
<div class="action-loader">
${this._loadingBackupInfo
? html`<ha-fade-in .delay=${250}>
<mwc-linear-progress
.indeterminate=${true}
></mwc-linear-progress>
<ha-progress-bar indeterminate></ha-progress-bar>
</ha-fade-in>`
: nothing}
</div>
@@ -464,7 +462,8 @@ class DialogRestart extends LitElement {
padding: 24px;
}
.action-loader {
height: 4px;
--ha-progress-bar-track-height: 4px;
--ha-progress-bar-border-radius: 0;
}
`,
];
@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiDotsVertical, mdiRestart } from "@mdi/js";
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
+1 -1
View File
@@ -76,7 +76,7 @@ export class TTSTryDialog extends LitElement {
@closed=${this._dialogClosed}
>
<ha-textarea
autogrow
resize="auto"
id="message"
.label=${this.hass.localize("ui.dialogs.tts-try.message")}
.placeholder=${this.hass.localize(
@@ -2,8 +2,8 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-progress-ring";
import "../../components/ha-spinner";
import "../../components/progress/ha-progress-ring";
import { ON, UNAVAILABLE } from "../../data/entity/entity";
import {
updateCanInstall,
-1
View File
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../types/service-worker.d.ts" />
/* eslint-env serviceworker */
import type { RouteHandler } from "workbox-core";
import { cacheNames } from "workbox-core";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
+1
View File
@@ -199,6 +199,7 @@ class HassSubpage extends LitElement {
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--ha-space-2);
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
:host([narrow]) #fab.tabs {
bottom: calc(84px + var(--safe-area-inset-bottom, 0px));
+21 -1
View File
@@ -1,6 +1,12 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { canShowPage } from "../common/config/can_show_page";
@@ -28,6 +34,8 @@ export interface PageNavigation {
not_component?: string | string[];
core?: boolean;
advancedOnly?: boolean;
/** Hide from non-admin users in filtered navigation and quick bar. */
adminOnly?: boolean;
iconPath?: string;
iconSecondaryPath?: string;
iconViewBox?: string;
@@ -75,6 +83,8 @@ export class HassTabsSubpage extends LitElement {
@state() private _activeTab?: PageNavigation;
@query(".content") private _content?: HTMLDivElement;
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
@@ -211,6 +221,15 @@ export class HassTabsSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
public focusContentScroller() {
if (!this._content) {
return;
}
this._content.style.outline = "none";
this._content.focus({ preventScroll: true });
}
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
@@ -403,6 +422,7 @@ export class HassTabsSubpage extends LitElement {
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--ha-space-2);
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
:host([narrow][show-tabs]) #fab {
bottom: calc(84px + var(--safe-area-inset-bottom, 0px));
+1
View File
@@ -34,6 +34,7 @@ const COMPONENTS = {
light: () => import("../panels/light/ha-panel-light"),
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
home: () => import("../panels/home/ha-panel-home"),
notfound: () => import("../panels/notfound/ha-panel-notfound"),
};
+37 -3
View File
@@ -1,12 +1,16 @@
import { mdiClose } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
import "../components/ha-toast";
import type { ToastClosedEventDetail } from "../components/ha-toast";
import type {
ToastCloseReason,
ToastClosedEventDetail,
} from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
@@ -15,13 +19,18 @@ export interface ShowToastParams {
message:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string> };
announceMessage?:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string> };
action?: ToastActionParams;
onClose?: (reason: ToastCloseReason) => void;
duration?: number;
dismissable?: boolean;
}
export interface ToastActionParams {
action: () => void;
primary?: boolean;
text:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string> };
@@ -36,11 +45,19 @@ class NotificationManager extends LitElement {
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
private _showDialogId = 0;
public async showDialog(parameters: ShowToastParams) {
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
if (parameters.duration === 0) {
this._parameters = undefined;
return;
@@ -56,11 +73,18 @@ class NotificationManager extends LitElement {
}
await this.updateComplete;
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
}
private _toastClosed(_ev: HASSDomEvent<ToastClosedEventDetail>) {
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
const onClose = this._parameters?.onClose;
this._parameters = undefined;
onClose?.(ev.detail.reason);
}
protected render() {
@@ -75,13 +99,23 @@ class NotificationManager extends LitElement {
this._parameters.message.args
)
: this._parameters.message}
.announceText=${this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined}
.timeoutMs=${this._parameters.duration!}
@toast-closed=${this._toastClosed}
>
${this._parameters?.action
? html`
<ha-button
appearance="plain"
appearance=${ifDefined(
this._parameters?.action.primary ? undefined : "plain"
)}
size="small"
slot="action"
@click=${this._buttonClicked}
+14 -14
View File
@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import type { Auth } from "home-assistant-js-websocket";
import {
createConnection,
@@ -16,6 +15,8 @@ import {
} from "../common/auth/token_storage";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
import {
addSearchParam,
extractSearchParam,
@@ -33,19 +34,18 @@ import {
onboardIntegrationStep,
} from "../data/onboarding";
import { subscribeUser } from "../data/ws-user";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
import { HassElement } from "../state/hass-element";
import type { HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
import { registerServiceWorker } from "../util/register-service-worker";
import "../components/progress/ha-progress-bar";
import "./onboarding-analytics";
import "./onboarding-create-user";
import "./onboarding-loading";
import "./onboarding-welcome";
import "./onboarding-welcome-links";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { navigate } from "../common/navigate";
import { mainWindow } from "../common/dom/get_main_window";
type OnboardingEvent =
| {
@@ -126,9 +126,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
};
protected render() {
return html`<mwc-linear-progress
.progress=${this._progress}
></mwc-linear-progress>
return html`<ha-progress-bar .value=${this._progress}></ha-progress-bar>
<ha-card>
<div class="card-content">${this._renderStep()}</div>
</ha-card>
@@ -318,7 +316,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
history.replaceState(null, "", location.pathname);
await this._connectHass(auth);
const currentStep = steps.findIndex((stp) => !stp.done);
const singelStepProgress = 1 / steps.length;
const singelStepProgress = 100 / steps.length;
this._progress = currentStep * singelStepProgress + singelStepProgress;
} else {
this._init = true;
@@ -333,7 +331,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
}
private _handleProgress(ev: HASSDomEvent<OnboardingProgressEvent>) {
const stepSize = 1 / this._steps!.length;
const stepSize = 100 / this._steps!.length;
if (ev.detail.increase) {
this._progress += ev.detail.increase * stepSize;
}
@@ -355,7 +353,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
this._init = false;
this._restoring = stepResult.result?.restore;
if (!this._restoring) {
this._progress = 0.25;
this._progress = 25;
} else {
navigate(
`${location.pathname}?${addSearchParam({ page: `restore_backup${this._restoring === "cloud" ? "_cloud" : ""}` })}`
@@ -364,7 +362,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
} else if (stepResult.type === "user") {
const result = stepResult.result as OnboardingResponses["user"];
this._loading = true;
this._progress = 0.5;
this._progress = 50;
enableWrite();
try {
const auth = await getAuth({
@@ -381,10 +379,10 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
this._loading = false;
}
} else if (stepResult.type === "core_config") {
this._progress = 0.75;
this._progress = 75;
// We do nothing
} else if (stepResult.type === "analytics") {
this._progress = 1;
this._progress = 100;
// We do nothing
} else if (stepResult.type === "integration") {
this._loading = true;
@@ -505,7 +503,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
.card-content {
padding: 32px;
}
mwc-linear-progress {
ha-progress-bar {
--ha-progress-bar-border-radius: 0;
--ha-progress-bar-track-height: 4px;
position: fixed;
top: 0;
left: 0;
@@ -1,12 +1,12 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-alert";
import "../../components/ha-button";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/progress/ha-progress-bar";
import type { BackupOnboardingInfo } from "../../data/backup_onboarding";
import { onBoardingStyles } from "../styles";
import { fireEvent } from "../../common/dom/fire_event";
@customElement("onboarding-restore-backup-status")
class OnboardingRestoreBackupStatus extends LitElement {
@@ -33,7 +33,7 @@ class OnboardingRestoreBackupStatus extends LitElement {
${this.backupInfo.state === "restore_backup"
? html`
<div class="loading">
<mwc-linear-progress indeterminate></mwc-linear-progress>
<ha-progress-bar indeterminate></ha-progress-bar>
</div>
`
: html`
@@ -92,7 +92,7 @@ class OnboardingRestoreBackupStatus extends LitElement {
padding: 16px 0;
font-size: var(--ha-font-size-l);
}
mwc-linear-progress {
ha-progress-bar {
width: 100%;
}
`,
@@ -197,7 +197,7 @@ class DialogCalendarEventEditor extends LitElement {
)}
.value=${this._description}
@change=${this._handleDescriptionChanged}
autogrow
resize="auto"
></ha-textarea>
<ha-entity-picker
name="calendar"
+10 -14
View File
@@ -1,3 +1,4 @@
import { TZDate } from "@date-fns/tz";
import type { CalendarOptions } from "@fullcalendar/core";
import { Calendar } from "@fullcalendar/core";
import allLocales from "@fullcalendar/core/locales-all";
@@ -16,7 +17,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoize from "memoize-one";
import { TZDate } from "@date-fns/tz";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
import { useAmPm } from "../../common/datetime/use_am_pm";
@@ -25,7 +25,6 @@ import { supportsFeature } from "../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-button";
import "../../components/ha-button-toggle-group";
import "../../components/ha-fab";
import "../../components/ha-icon-button-next";
import "../../components/ha-icon-button-prev";
import type {
@@ -218,14 +217,10 @@ export class HAFullCalendar extends LitElement {
<div id="calendar"></div>
${this.addFab && this._hasMutableCalendars
? html`<ha-fab
slot="fab"
.label=${this.hass.localize("ui.components.calendar.event.add")}
extended
@click=${this._createEvent}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>`
? html`<ha-button size="large" slot="fab" @click=${this._createEvent}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.components.calendar.event.add")}
</ha-button>`
: nothing}
`;
}
@@ -559,13 +554,14 @@ export class HAFullCalendar extends LitElement {
--ha-icon-button-size: 32px;
}
ha-fab {
ha-button[slot="fab"] {
position: absolute;
bottom: 16px;
right: 16px;
inset-inline-end: 16px;
bottom: var(--ha-space-4);
right: var(--ha-space-4);
inset-inline-end: var(--ha-space-4);
inset-inline-start: initial;
z-index: 1;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
#calendar {
+113 -38
View File
@@ -21,8 +21,17 @@ import "../../components/ha-spinner";
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 type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../../data/integration";
@@ -46,6 +55,8 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
@state() private _error?: string = undefined;
@state() private _errorCalendars: string[] = [];
@state() private _entityRegistry?: EntityRegistryEntry[];
@state()
@@ -59,6 +70,8 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
private _end?: Date;
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
private _showPaneController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 750,
});
@@ -78,6 +91,7 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
super.disconnectedCallback();
this._mql?.removeListener(this._setIsMobile!);
this._mql = undefined;
this._unsubscribeAll();
}
private _setIsMobile = (ev: MediaQueryListEvent) => {
@@ -90,15 +104,11 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
this._entityRegistry = entities;
// Refresh calendars when entity registry updates (includes color changes)
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
// Refetch events if view dates are available (handles both initial load and color updates)
// Resubscribe events if view dates are available (handles both initial load and color updates)
if (this._start && this._end) {
this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
).then((result) => {
this._events = result.events;
this._handleErrors(result.errors);
this._unsubscribeAll().then(() => {
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
});
}
}),
@@ -212,19 +222,95 @@ class PanelCalendar extends SubscribeMixin(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: Event) {
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: Event) {
ev.stopPropagation();
const item = ev.currentTarget as HaDropdownItem;
const entityId = item.value as string;
@@ -240,13 +326,10 @@ class PanelCalendar extends SubscribeMixin(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
);
@@ -271,23 +354,15 @@ class PanelCalendar extends SubscribeMixin(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[]) {
+28 -33
View File
@@ -32,7 +32,7 @@ class PanelClimate extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _searchParams = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -99,41 +99,36 @@ class PanelClimate extends LitElement {
return html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
<div class="main-title">${this.hass.localize("panel.climate")}</div>
</div>
</div>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</hui-view-container>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
`;
}
@@ -3,6 +3,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type {
@@ -10,11 +11,10 @@ import type {
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import "../../../components/ha-button";
import "../../../components/ha-help-tooltip";
import "../../../components/ha-svg-icon";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-svg-icon";
import type { ApplicationCredential } from "../../../data/application_credential";
import {
deleteApplicationCredential,
@@ -30,7 +30,6 @@ import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
import { storage } from "../../../common/decorators/storage";
@customElement("ha-config-application-credentials")
export class HaConfigApplicationCredentials extends LitElement {
@@ -201,16 +200,16 @@ export class HaConfigApplicationCredentials extends LitElement {
</ha-help-tooltip>
`}
</div>
<ha-fab
<ha-button
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.add_application_credential"
)}
extended
size="large"
@click=${this._addApplicationCredential}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.application_credentials.picker.add_application_credential"
)}
</ha-button>
</hass-tabs-subpage-data-table>
`;
}
@@ -0,0 +1,179 @@
import { mdiOpenInNew, mdiPuzzle } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { saveFrontendUserData } from "../../../data/frontend";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { navigate } from "../../../common/navigate";
@customElement("ha-config-apps-info")
class HaConfigAppsInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.dashboard.apps.main")}
>
<div class="content">
<ha-card outlined>
<div class="card-content">
<div class="header">
<ha-svg-icon class="icon" .path=${mdiPuzzle}></ha-svg-icon>
<h1>
${this.hass.localize(
"ui.panel.config.apps.info.what_is_an_app"
)}
</h1>
</div>
<p>
${this.hass.localize(
"ui.panel.config.apps.info.what_is_an_app_description"
)}
</p>
</div>
</ha-card>
<ha-card outlined>
<div class="card-content">
<h2>
${this.hass.localize(
"ui.panel.config.apps.info.why_not_available"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.config.apps.info.why_not_available_description"
)}
</p>
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.apps.info.installation_hint"
)}
</ha-alert>
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href=${documentationUrl(this.hass, "/apps/")}
target="_blank"
rel="noreferrer"
>
${this.hass.localize("ui.panel.config.apps.info.learn_more")}
<ha-svg-icon slot="icon" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-button>
<ha-button @click=${this._dismiss} variant="danger">
${this.hass.localize("ui.panel.config.apps.info.dismiss")}
</ha-button>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
private async _dismiss(): Promise<void> {
try {
await saveFrontendUserData(this.hass.connection, "core", {
...this.hass.userData,
apps_info_dismissed: true,
});
} catch (err) {
showAlertDialog(this, { text: (err as Error).message });
return;
}
navigate("/config", { replace: true });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.content {
max-width: 600px;
margin: 0 auto;
padding: var(--ha-space-4);
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
.card-content {
padding: var(--ha-space-4);
}
.header {
display: flex;
align-items: center;
gap: var(--ha-space-3);
margin-bottom: var(--ha-space-4);
}
.icon {
width: 40px;
height: 40px;
flex-shrink: 0;
color: var(--primary-color);
}
h1 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: 500;
}
h2 {
margin: 0 0 var(--ha-space-3);
font-size: var(--ha-font-size-l);
font-weight: 500;
}
p {
margin: 0 0 var(--ha-space-3);
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
}
ha-alert {
display: block;
margin-top: var(--ha-space-2);
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2);
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
a {
text-decoration: none;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-apps-info": HaConfigAppsInfo;
}
}
@@ -10,8 +10,8 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input-search";
@@ -157,16 +157,10 @@ export class HaConfigAppsInstalled extends LitElement {
</div>
</div>
<a href="/config/apps/available">
<ha-fab
.label=${this.hass.localize(
"ui.panel.config.apps.installed.add_app"
)}
extended
>
<ha-svg-icon slot="icon" .path=${mdiStorePlus}></ha-svg-icon>
</ha-fab>
</a>
<ha-button size="large" href="/config/apps/available">
<ha-svg-icon slot="start" .path=${mdiStorePlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.apps.installed.add_app")}
</ha-button>
</hass-subpage>
`;
}
@@ -270,7 +264,7 @@ export class HaConfigAppsInstalled extends LitElement {
cursor: pointer;
}
ha-fab {
ha-button[size="large"] {
position: fixed;
right: calc(var(--ha-space-4) + var(--safe-area-inset-right));
bottom: calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
@@ -279,6 +273,7 @@ export class HaConfigAppsInstalled extends LitElement {
);
inset-inline-start: initial;
z-index: 1;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
`,
];
@@ -5,7 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import "../../../components/data-table/ha-data-table";
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import { extractApiErrorMessage } from "../../../data/hassio/common";
@@ -116,13 +116,10 @@ export class HaConfigAppsRegistries extends LitElement {
id="registry"
has-fab
></ha-data-table>
<ha-fab
.label=${this.hass.localize("ui.panel.config.apps.registries.add")}
extended
@click=${this._showAddRegistryDialog}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
<ha-button size="large" @click=${this._showAddRegistryDialog}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.apps.registries.add")}
</ha-button>
</hass-subpage>
`;
}
@@ -187,13 +184,14 @@ export class HaConfigAppsRegistries extends LitElement {
ha-icon-button.delete {
color: var(--error-color);
}
ha-fab {
ha-button[size="large"] {
position: fixed;
right: calc(var(--ha-space-4) + var(--safe-area-inset-right));
bottom: calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
inset-inline-end: calc(var(--ha-space-4) + var(--safe-area-inset-right));
inset-inline-start: initial;
z-index: 1;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
`;
}
@@ -8,14 +8,14 @@ import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/data-table/ha-data-table";
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type {
HassioAddonInfo,
HassioAddonsInfo,
HassioAddonRepository,
HassioAddonsInfo,
} from "../../../data/hassio/addon";
import { fetchHassioAddonsInfo } from "../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../data/hassio/common";
@@ -195,13 +195,10 @@ export class HaConfigAppsRepositories extends LitElement {
id="slug"
has-fab
></ha-data-table>
<ha-fab
.label=${this.hass.localize("ui.panel.config.apps.repositories.add")}
extended
@click=${this._showAddRepositoryDialog}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
<ha-button size="large" @click=${this._showAddRepositoryDialog}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.apps.repositories.add")}
</ha-button>
</hass-subpage>
`;
}
@@ -295,13 +292,14 @@ export class HaConfigAppsRepositories extends LitElement {
ha-icon-button.delete {
color: var(--error-color);
}
ha-fab {
ha-button[size="large"] {
position: fixed;
right: calc(var(--ha-space-4) + var(--safe-area-inset-right));
bottom: calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
inset-inline-end: calc(var(--ha-space-4) + var(--safe-area-inset-right));
inset-inline-start: initial;
z-index: 1;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
`;
}
+11
View File
@@ -1,4 +1,5 @@
import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { HomeAssistant, Route } from "../../../types";
@@ -15,6 +16,12 @@ class HaConfigApps extends HassRouterPage {
protected routerOptions: RouterOptions = {
defaultPage: "installed",
beforeRender: () => {
if (!isComponentLoaded(this.hass.config, "hassio")) {
return "info";
}
return undefined;
},
routes: {
installed: {
tag: "ha-config-apps-installed",
@@ -32,6 +39,10 @@ class HaConfigApps extends HassRouterPage {
tag: "ha-config-apps-registries",
load: () => import("./ha-config-apps-registries"),
},
info: {
tag: "ha-config-apps-info",
load: () => import("./ha-config-apps-info"),
},
},
};
@@ -1,4 +1,6 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import {
mdiDelete,
mdiDotsVertical,
@@ -15,7 +17,7 @@ import {
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import {
@@ -24,9 +26,10 @@ import {
type AreasFloorHierarchy,
} from "../../../common/areas/areas-floor-hierarchy";
import { formatListWithAnds } from "../../../common/string/format-list";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-fab";
import "../../../components/ha-floor-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-sortable";
@@ -58,7 +61,6 @@ import {
} from "./show-dialog-area-registry-detail";
import { showAreasFloorsOrderDialog } from "./show-dialog-areas-floors-order";
import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-detail";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
const UNASSIGNED_FLOOR = "__unassigned__";
@@ -84,10 +86,12 @@ export class HaConfigAreasDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
private _searchParms = new URLSearchParams(window.location.search);
@state() private _hierarchy?: AreasFloorHierarchy;
@query("wa-popover") private _popover?: WaPopover;
private _searchParms = new URLSearchParams(window.location.search);
private _blockHierarchyUpdate = false;
private _blockHierarchyUpdateTimeout?: number;
@@ -318,27 +322,26 @@ export class HaConfigAreasDashboard extends LitElement {
`
: nothing}
</div>
<ha-fab
slot="fab"
class="floor"
.label=${this.hass.localize(
"ui.panel.config.areas.picker.create_floor"
)}
extended
@click=${this._createFloor}
<ha-button id="fab" slot="fab" size="large">
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.common.add")}
</ha-button>
<wa-popover
trap-focus
placement="top-start"
distance="8"
without-arrow
for="fab"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
<ha-fab
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.areas.picker.create_area"
)}
extended
@click=${this._createArea}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
<ha-button appearance="filled" @click=${this._createFloor}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.picker.create_floor")}
</ha-button>
<ha-button appearance="filled" @click=${this._createArea}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.picker.create_area")}
</ha-button>
</wa-popover>
</hass-tabs-subpage>
`;
}
@@ -559,6 +562,7 @@ export class HaConfigAreasDashboard extends LitElement {
}
private _createFloor() {
this._popover?.hide();
this._openFloorDialog();
}
@@ -584,6 +588,7 @@ export class HaConfigAreasDashboard extends LitElement {
}
private _createArea() {
this._popover?.hide();
this._openAreaDialog();
}
@@ -725,6 +730,14 @@ export class HaConfigAreasDashboard extends LitElement {
align-items: center;
overflow-wrap: anywhere;
}
wa-popover::part(body) {
gap: var(--ha-space-2);
background-color: transparent;
border-color: transparent;
box-shadow: none;
padding: 0;
}
`;
}
@@ -450,8 +450,10 @@ export class HaAutomationAddSearch extends LitElement {
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
private _createFuseIndex = (
states: PickerComboBoxItem[],
keys: FuseWeightedKey[]
) => Fuse.createIndex(keys, states);
private _fuseIndexes = {
area: memoizeOne((states: PickerComboBoxItem[]) =>
@@ -165,10 +165,9 @@ class DialogAutomationSave extends LitElement implements HassDialog {
"ui.panel.config.automation.editor.description.placeholder"
)}
name="description"
autogrow
resize="auto"
.value=${this._newDescription}
.helper=${supportsMarkdownHelper(this.hass.localize)}
helperPersistent
.hint=${supportsMarkdownHelper(this.hass.localize)}
@input=${this._valueChanged}
></ha-textarea>`
: nothing}
@@ -6,7 +6,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import "../../../components/ha-fab";
import type { BlueprintAutomationConfig } from "../../../data/automation";
import { fetchBlueprints } from "../../../data/blueprint";
import { HaBlueprintGenericEditor } from "../blueprint/blueprint-generic-editor";
@@ -56,16 +55,16 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
: nothing}
${this.renderCard()}
<ha-fab
<ha-button
slot="fab"
size="large"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.disabled=${this.saving}
extended
@click=${this._saveAutomation}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
<ha-svg-icon slot="start" .path=${mdiContentSave}></ha-svg-icon>
${this.hass.localize("ui.common.save")}
</ha-button>
`;
}
@@ -109,8 +108,9 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
)
);
}
ha-fab {
ha-button[slot="fab"] {
position: fixed;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
`,
];
@@ -73,13 +73,16 @@ export class HaPlatformCondition extends LitElement {
}
if (
oldValue?.condition !== this.condition?.condition &&
this.condition &&
oldValue?.condition !== this.condition.condition &&
this.description?.fields
) {
const hadOptions = "options" in this.condition;
const updatedOptions = this.condition.options
? { ...this.condition.options }
: {};
const loadDefaults = !hadOptions;
let updatedDefaultValue = false;
const updatedOptions = {};
const loadDefaults = !("options" in this.condition);
// Set mandatory bools without a default value to false
Object.entries(this.description.fields).forEach(([key, field]) => {
if (
@@ -106,7 +109,7 @@ export class HaPlatformCondition extends LitElement {
updatedOptions[key] = field.default;
}
});
if (updatedDefaultValue) {
if (!hadOptions || updatedDefaultValue) {
fireEvent(this, "value-changed", {
value: {
...this.condition,
@@ -1,6 +1,5 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../../components/ha-textarea";
import type { TemplateCondition } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
@@ -32,7 +32,6 @@ import { promiseTimeout } from "../../../common/util/promise-timeout";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-fab";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
@@ -148,7 +147,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._newAutomationId &&
changedProps.has("entityRegistry")
) {
const automation = this.entityRegistry.find(
const automation = this.entityRegistry?.find(
(entity: EntityRegistryEntry) =>
entity.platform === "automation" &&
entity.unique_id === this._newAutomationId
@@ -547,19 +546,19 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.showErrors=${false}
disable-fullscreen
></ha-yaml-editor>
<ha-fab
<ha-button
slot="fab"
size="large"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.disabled=${this.saving}
extended
@click=${this._handleSaveAutomation}
>
<ha-svg-icon
slot="icon"
slot="start"
.path=${mdiContentSave}
></ha-svg-icon>
</ha-fab>`
${this.hass.localize("ui.common.save")}
</ha-button>`
: nothing}
</div>
</hass-subpage>
@@ -51,7 +51,7 @@ import type {
HaDropdownSelectEvent,
} from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-fab";
import "../../../components/ha-button";
import "../../../components/ha-filter-blueprints";
import "../../../components/ha-filter-categories";
import "../../../components/ha-filter-devices";
@@ -691,16 +691,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</ha-button>
</div>`
: nothing}
<ha-fab
slot="fab"
.label=${this.hass.localize(
<ha-button slot="fab" size="large" @click=${this._createNew}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.automation.picker.add_automation"
)}
extended
@click=${this._createNew}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</ha-button>
</hass-tabs-subpage-data-table>
<ha-dropdown
id="overflow-menu"
@@ -50,13 +50,14 @@ export const automationScriptEditorStyles: CSSResult = css`
p {
margin-bottom: 0;
}
ha-fab {
ha-button[slot="fab"] {
position: fixed;
right: calc(16px + var(--safe-area-inset-right, 0px));
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
ha-fab.dirty {
ha-button[slot="fab"].dirty {
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
}
ha-tooltip ha-svg-icon {
@@ -93,7 +94,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
entityRegistry!: EntityRegistryEntry[];
entityRegistry?: EntityRegistryEntry[];
@state() protected dirty = false;
@@ -234,7 +235,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
goBack("/config");
return;
}
const entity = this.entityRegistry.find(
const entity = this.entityRegistry?.find(
(ent) => ent.platform === domain && ent.unique_id === id
);
if (entity) {
@@ -15,7 +15,7 @@ import {
extractSearchParam,
removeSearchParam,
} from "../../../common/url/search-params";
import "../../../components/ha-fab";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { SidebarConfig } from "../../../data/automation";
@@ -112,16 +112,16 @@ export const ManualEditorMixin = <TConfig>(
${this.renderContent()}
</div>
<div class="fab-positioner">
<ha-fab
<ha-button
slot="fab"
size="large"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.disabled=${this.saving}
extended
@click=${this.saveConfig}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
<ha-svg-icon slot="start" .path=${mdiContentSave}></ha-svg-icon>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
</div>
<div class="sidebar-positioner">
@@ -17,7 +17,6 @@ import { ensureArray } from "../../../common/array/ensure-array";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type {
+5 -4
View File
@@ -96,13 +96,14 @@ export const saveFabStyles = css`
:host {
overflow: hidden;
}
ha-fab {
ha-button[slot="fab"] {
position: absolute;
right: calc(16px + var(--safe-area-inset-right, 0px));
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
ha-fab.dirty {
ha-button[slot="fab"].dirty {
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
}
`;
@@ -129,14 +130,14 @@ export const manualEditorStyles = css`
justify-content: flex-end;
}
.fab-positioner ha-fab {
.fab-positioner ha-button[slot="fab"] {
position: fixed;
right: unset;
left: unset;
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
}
.fab-positioner ha-fab.dirty {
.fab-positioner ha-button[slot="fab"].dirty {
bottom: calc(16px + var(--safe-area-inset-bottom, 0px));
}
@@ -108,13 +108,16 @@ export class HaPlatformTrigger extends LitElement {
}
if (
oldValue?.trigger !== this.trigger?.trigger &&
this.trigger &&
oldValue?.trigger !== this.trigger.trigger &&
this.description?.fields
) {
const hadOptions = "options" in this.trigger;
const updatedOptions = this.trigger.options
? { ...this.trigger.options }
: {};
const loadDefaults = !hadOptions;
let updatedDefaultValue = false;
const updatedOptions = {};
const loadDefaults = !("options" in this.trigger);
// Set mandatory bools without a default value to false
Object.entries(this.description.fields).forEach(([key, field]) => {
if (
@@ -142,7 +145,7 @@ export class HaPlatformTrigger extends LitElement {
}
});
if (updatedDefaultValue) {
if (!hadOptions || updatedDefaultValue) {
fireEvent(this, "value-changed", {
value: {
...this.trigger,
@@ -1,4 +1,3 @@
import "../../../../../components/ha-textarea";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@@ -31,7 +31,6 @@ import type {
HaDropdownSelectEvent,
} from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-fab";
import "../../../components/ha-filter-states";
import "../../../components/ha-icon";
import "../../../components/ha-icon-next";
@@ -477,24 +476,24 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
></ha-filter-states>
${!this._needsOnboarding
? html`
<ha-fab
<ha-button
slot="fab"
size="large"
?disabled=${backupInProgress}
.label=${this.hass.localize(
"ui.panel.config.backup.backups.new_backup"
)}
extended
@click=${this._newBackup}
>
${backupInProgress
? html`<div slot="icon" class="loading">
? html`<div slot="start" class="loading">
<ha-spinner .size=${"small"}></ha-spinner>
</div>`
: html`<ha-svg-icon
slot="icon"
slot="start"
.path=${mdiPlus}
></ha-svg-icon>`}
</ha-fab>
${this.hass.localize(
"ui.panel.config.backup.backups.new_backup"
)}
</ha-button>
`
: nothing}
</hass-tabs-subpage-data-table>
@@ -6,8 +6,8 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-fab";
import "../../../components/ha-icon";
import "../../../components/ha-icon-next";
import "../../../components/ha-icon-overflow-menu";
@@ -39,7 +39,6 @@ import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboard
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@customElement("ha-config-backup-overview")
class HaConfigBackupOverview extends LitElement {
@@ -228,21 +227,15 @@ class HaConfigBackupOverview extends LitElement {
: nothing}
</div>
<ha-fab
<ha-button
slot="fab"
?disabled=${backupInProgress}
.label=${this.hass.localize(
"ui.panel.config.backup.overview.new_backup"
)}
extended
size="large"
.loading=${backupInProgress}
@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>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.backup.overview.new_backup")}
</ha-button>
</hass-subpage>
`;
}
@@ -26,7 +26,6 @@ import type {
} from "../../../components/data-table/ha-data-table";
import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-button";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-svg-icon";
@@ -377,16 +376,12 @@ class HaBlueprintOverview extends LitElement {
.path=${mdiHelpCircleOutline}
@click=${this._showHelp}
></ha-icon-button>
<ha-fab
slot="fab"
.label=${this.hass.localize(
<ha-button slot="fab" size="large" @click=${this._addBlueprintClicked}>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.blueprint.overview.add_blueprint"
)}
extended
@click=${this._addBlueprintClicked}
>
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-fab>
</ha-button>
</hass-tabs-subpage-data-table>
`;
}
@@ -8,7 +8,6 @@ import "../../../../components/ha-markdown-element";
import "../../../../components/ha-dialog";
import "../../../../components/ha-select";
import "../../../../components/ha-spinner";
import "../../../../components/ha-textarea";
import { fetchSupportPackage } from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types";
import { fileDownload } from "../../../../util/file_download";
@@ -101,7 +101,7 @@ export class DialogTryTts extends LitElement {
>
<div>
<ha-textarea
autogrow
resize="auto"
id="message"
autofocus
.label=${this.hass.localize(
@@ -36,6 +36,7 @@ import {
import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -111,12 +112,14 @@ const randomTip = (openFn: any, hass: HomeAssistant, narrow: boolean) => {
>`,
};
tips.push(
{
if (hass.user?.is_admin) {
tips.push({
content: hass.localize("ui.tips.key_c_tip", localizeParam),
weight: 1,
narrow: false,
},
});
}
tips.push(
{
content: hass.localize("ui.tips.key_m_tip", localizeParam),
weight: 1,
@@ -169,25 +172,37 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
};
private _pages = memoizeOne(
(cloudStatus, isCloudLoaded, hasExternalSettings) => [
isCloudLoaded
? [
{
component: "cloud",
path: "/config/cloud",
name: "Home Assistant Cloud",
info: cloudStatus,
iconPath: mdiCloudLock,
iconColor: "#3B808E",
translationKey: "cloud",
},
...configSections.dashboard,
]
: configSections.dashboard,
hasExternalSettings ? configSections.dashboard_external_settings : [],
configSections.dashboard_2,
configSections.dashboard_3,
]
(
cloudStatus,
isCloudLoaded,
hasExternalSettings,
isAppsInfoDismissed,
isHassioLoaded
) => {
const filterApps = (pages: PageNavigation[]) =>
isAppsInfoDismissed && !isHassioLoaded
? pages.filter((page) => page.path !== "/config/apps")
: pages;
return [
isCloudLoaded
? filterApps([
{
component: "cloud",
path: "/config/cloud",
name: "Home Assistant Cloud",
info: cloudStatus,
iconPath: mdiCloudLock,
iconColor: "#3B808E",
translationKey: "cloud",
},
...configSections.dashboard,
])
: filterApps(configSections.dashboard),
hasExternalSettings ? configSections.dashboard_external_settings : [],
configSections.dashboard_2,
configSections.dashboard_3,
];
}
);
public hassSubscribe(): UnsubscribeFunc[] {
@@ -338,7 +353,9 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
${this._pages(
this.cloudStatus,
isComponentLoaded(this.hass.config, "cloud"),
this.hass.auth.external?.config.hasSettingsScreen
this.hass.auth.external?.config.hasSettingsScreen,
this.hass.userData?.apps_info_dismissed,
isComponentLoaded(this.hass.config, "hassio")
).map((categoryPages) =>
categoryPages.length === 0
? nothing
@@ -12,8 +12,8 @@ import "../../../components/ha-alert";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-progress-ring";
import "../../../components/ha-spinner";
import "../../../components/progress/ha-progress-ring";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { subscribeDeviceRegistry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
@@ -69,7 +69,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
}
private async _parse() {
const sentences = this._sentencesInput.value
const sentences = (this._sentencesInput.value || "")
.split("\n")
.filter((a) => a !== "");
const { results } = await debugAgent(this.hass, sentences, this._language!);
@@ -139,7 +139,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
`
: nothing}
<ha-textarea
autogrow
resize="auto"
.label=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.assist.sentences"
)}
@@ -11,7 +11,6 @@ import {
NetworkType,
getMatterNodeDiagnostics,
} from "../../../../../../data/matter";
import { getMatterLockInfo } from "../../../../../../data/matter-lock";
import type { HomeAssistant } from "../../../../../../types";
import { showMatterManageFabricsDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-manage-fabrics";
import { showMatterOpenCommissioningWindowDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-open-commissioning-window";
@@ -109,23 +108,14 @@ export const getMatterDeviceActions = async (
);
if (lockEntity) {
try {
const lockInfo = await getMatterLockInfo(hass, lockEntity.entity_id);
if (lockInfo.supports_user_management) {
actions.push({
label: hass.localize(
"ui.panel.config.matter.device_actions.manage_lock"
),
icon: mdiAccountLock,
action: () =>
showMatterLockManageDialog(el, {
entity_id: lockEntity.entity_id,
}),
});
}
} catch {
// Lock info not available, skip lock management action
}
actions.push({
label: hass.localize("ui.panel.config.matter.device_actions.manage_lock"),
icon: mdiAccountLock,
action: () =>
showMatterLockManageDialog(el, {
entity_id: lockEntity.entity_id,
}),
});
}
return actions;

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