Compare commits

...
Author SHA1 Message Date
Aidan Timson 9f1e4ea942 Type fix 2026-04-13 12:37:16 +01:00
Aidan Timson d46c686118 Integrated into toolbar layout 2026-04-13 12:19:30 +01:00
Aidan Timson 3aea6a13ec Reduce top position 2026-04-13 12:19:30 +01:00
Aidan Timson f1c26dc747 Add floating support for badges 2026-04-13 12:19:30 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d7f143a65a Bump actions/github-script from 8.0.0 to 9.0.0 (#51539)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8.0.0 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/ed597411d8f924073f98dfc5c65a23a2325f34cd...3a2844b7e9c422d3c10d287c895573f7108da1b3)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 13:17:21 +02:00
WendelinandGitHub 9cce20bad1 Enhance sun condition automation: add 'between' option and improve duration formatting (#51502) 2026-04-13 11:58:20 +02:00
c9ad84b234 Register custom dashboard strategies (#51310)
* Allow custom dashboard strategies to register for the add dashboard dialog

Adds a window.customDashboardStrategies registration mechanism (mirroring
window.customCards) so third-party strategies can appear in the new dashboard
picker. Only dashboard-level strategies are surfaced; view and section
strategies are excluded. Custom strategies appear in their own section and
are included in search results.

https://claude.ai/code/session_019MXBdWUQrFQfH54QVjbq8y

* Rename custom dashboards heading to community dashboards

https://claude.ai/code/session_019MXBdWUQrFQfH54QVjbq8y

* Consolidate into a single customStrategies registry for all strategy types

The window.customStrategies array now accepts entries with a strategyType
field (dashboard, view, or section). The dialog filters for dashboard
strategies. This allows future use of the same registry for view and
section strategy registration.

https://claude.ai/code/session_019MXBdWUQrFQfH54QVjbq8y

* Space tokens

* Space tokens

* Add support for images

* Allow single image from custom strategy

* Load needed resources for new dashboard dialog

* Preload custom strategies

* Catch potential error

* Reset if error

* Improve typing

* Cache module resources to avoid duplicate loads

* Revert "Cache module resources to avoid duplicate loads"

This reverts commit 87bbcc04510034111b95073fafb0952e7dc2335f.

* Set a max height for dashboard images (match max height of current core images)

* Remove image support

---------

Co-authored-by: Claude <[email protected]>
2026-04-13 11:57:59 +02:00
WendelinandGitHub cb89b8aea8 Use wa-tag for target picker chips (#51482) 2026-04-13 11:57:46 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a5f4885d95 Bump home-assistant/actions from 5752577ea7cc5aefb064b0b21432f18fe4d6ba90 to f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b (#51535)
Bump home-assistant/actions

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

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 11:08:45 +02:00
Aidan TimsonandGitHub e2e114cb4e Add shared editor for all more info hints, add lights (#51542)
* Add more info redirect editor for light favorites

* Merge all hint feature editors to one shared component

* Fix
2026-04-13 11:07:55 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4a0284455d Bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#51537)
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e...cef221092ed1bacb1cc03d23a2d87d1d172e277b)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 11:06:10 +02:00
JamesFromITandGitHub d220eba9f7 Make history page title a semantic heading (#51527) 2026-04-13 08:39:50 +00:00
Paul BotteinandGitHub 2edb0325aa Improve box shadow design tokens with multi-layer shadows (#51378) 2026-04-13 10:36:17 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2e1582a9c1 Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#51538)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 09:36:01 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
006cdf088a Bump release-drafter/release-drafter from 7.1.1 to 7.2.0 (#51536)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 09:35:46 +01:00
WendelinandGitHub d9b0bf21c0 Fix register admin quick search shortcuts (#51540) 2026-04-13 08:56:03 +01:00
JamesFromITandGitHub 7df059b4cf Make media management dialog heading a semantic heading element (#51522) 2026-04-13 06:41:36 +00:00
JamesFromITandGitHub 4cfc0dd6c3 Make media browser tab title a semantic heading element (#51528) 2026-04-13 06:36:32 +00:00
fb9f182dcc Fix: Center more-info-media_player component volume buttons (#51517)
Co-authored-by: Copilot <[email protected]>
2026-04-13 06:33:05 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
880b226d10 Update dependency sinon to v21.1.0 (#51529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 07:04:53 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
031e6ea789 Update dependency prettier to v3.8.2 (#51534)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 07:04:15 +02:00
pcan08andGitHub d025a842c4 Use header-subtitle in dialog-edit-home (#51525)
Replace the custom `<p class="description">` element and its associated
CSS with the built-in `headerSubtitle` property on `ha-dialog`.
2026-04-12 11:27:11 +02:00
WendelinandGitHub 775f145c9f Add ha-progress-bar highlight style variables (#51489)
Enhance ha-progress-bar styles and properties for improved customization
2026-04-12 09:51:01 +02:00
f9caf5365e Hide internal panels from navigation picker (#51497)
* Hide _my_redirect and notfound panels from navigation picker

These internal panels are not useful navigation targets and should not
appear in the "Other routes" section of the navigation picker.

https://claude.ai/code/session_01DT6YNh9gjLpTztxA6z79w5

* Address review: use panel constants and move to module level

- Add MY_REDIRECT_PANEL constant to src/data/panel.ts
- Use NOT_FOUND_PANEL and MY_REDIRECT_PANEL instead of string literals
- Move HIDDEN_PANELS to module level since it doesn't need recreation

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Add SYSTEM_PANELS constant and use in navigation picker and quick bar

- Add APP_PANEL constant and SYSTEM_PANELS array to data/panel.ts
- Use SYSTEM_PANELS in ha-navigation-picker.ts and quick_bar.ts
- Remove obsolete hassio panel filter from quick bar (no longer exists)
- Also hides the app panel from navigation picker

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
2026-04-12 09:49:15 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b1419b7761 Update vitest monorepo to v4.1.4 (#51524)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-12 07:45:00 +00:00
be0a673d4e Add error handling for backup creation failures (#51520)
* Show error details when backup creation fails

When generateBackup or generateBackupWithAutomaticSettings raises an
error (e.g., not enough free space), the error details are now shown
to the user in an alert dialog instead of only being logged to the
console.

https://claude.ai/code/session_01XWxeS4ZxxZfnt8h2pLsSMn

* Consolidate duplicate try-catch into single block in _newBackup

https://claude.ai/code/session_01XWxeS4ZxxZfnt8h2pLsSMn

---------

Co-authored-by: Claude <[email protected]>
2026-04-12 09:39:10 +02:00
Aidan TimsonandGitHub 8e31316692 Add link to UX design in issue template (#51503) 2026-04-12 09:38:01 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f9db26166f Update dependency typescript-eslint to v8.58.1 (#51514)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 11:00:39 +02:00
WendelinandGitHub 7ceba8d231 Add context groups (#51471) 2026-04-10 16:03:34 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2a0b4c8f18 Update vitest monorepo to v4.1.3 (#51505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 10:52:27 +00:00
Aidan TimsonandGitHub 6c762e0105 Ignore local opencode directory (#51504) 2026-04-10 12:44:48 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4ceb4c3c2c Update dependency marked to v18 (#51499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 08:58:35 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cebdb46989 Update dependency jsdom to v29.0.2 (#51498)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 08:42:18 +02:00
5aeae9ffa5 Fix duplicate "Add custom path" entry in navigation picker (#51496)
The navigation picker's _getItems was adding an "Add custom path" item,
but ha-picker-combo-box already adds one when allowCustomValue is set
and there's a search string. Remove the duplicate from _getItems since
the combo box handles it via the customValueLabel prop.

https://claude.ai/code/session_01NAB8bo1B6HuGFwKZVbvL1S

Co-authored-by: Claude <[email protected]>
2026-04-10 08:19:58 +03:00
2ce62841cf Settings dashboard repairs and updates design update (#51491)
* Reuse headings for config dashboard repairs and updates

* Keep headings internal to card and remove icons

* Merge headings into components

* Remove extra component for heading

* Use correct back links

Co-authored-by: Paul Bottein <[email protected]>

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-04-09 20:29:56 +02:00
TimothyandGitHub 63c9b85e6c Android externalAppV2 (#51446) 2026-04-09 16:34:32 +02:00
Petar PetrovandGitHub 03ace97a7e Enable zoom and pan on sankey charts (#51488) 2026-04-09 16:32:12 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9edcfaf6b3 Update dependency @lokalise/node-api to v15.7.1 (#51490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 14:53:58 +01:00
5cb7fdbfed Add search bar to integration page (#51485)
Co-authored-by: Wendelin <[email protected]>
2026-04-09 15:16:01 +02:00
Petar PetrovandGitHub 5a0e1e89e6 Add click-to-open-more-info to energy and water sankey cards (#51487) 2026-04-09 11:03:28 +00:00
Aidan TimsonandGitHub 5ac6906943 Fix regressions for search inputs with focus scrollable changes (#51484) 2026-04-09 10:51:43 +01:00
cf1fb7751f Home dashboard: show hide welcome message header (#51401)
* Allow showing/hiding welcome message on home overview

Add a toggle in the edit overview dialog to show or hide the
"Welcome, [user]" greeting header on the home overview page,
following the same pattern as the existing summary enable/disable.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Move welcome message toggle into its own section in home editor

The welcome message is a greeting header, not a summary card, so it
now lives in a separate "Greeting" section above the "Summaries" section.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* rename hidden_welcome_message to hide_welcome_message

* Use ha-form boolean selector for welcome message toggle

Replace manual label/ha-switch markup with ha-form using a boolean
selector for better accessibility and consistency with the rest of
the codebase.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Add helper text to welcome message toggle in home editor

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Remove greeting section header

* Extract welcome message schema into a module-level constant

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-04-09 10:53:41 +02:00
Paul BotteinandGitHub 22f8ee0d79 Refactor integration page to build device/sub-entry tree in parent (#51374)
* Refactor integration page to build device/sub-entry tree in parent

* Avoid concurrency update

* Fix memoize

* Fix device name display
2026-04-09 11:02:56 +03:00
Bram KragtenandGitHub 9e7d162724 Use fieldname as fallback instead of trigger/condition (#51474) 2026-04-08 14:39:30 +00:00
Bram KragtenandGitHub 14addf02b8 Remove unused deps (#51473) 2026-04-08 15:38:42 +01:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
17bcf59c6a Update dependency browserslist-useragent-regexp to v4.1.4 (#51470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 14:19:40 +01:00
Stefan AgnerandGitHub 0b1aa4a901 Rename "Registries" menu entry to "Registry credentials" (#51469)
Container registries can't be added really, they are part of an image
name. So the menu entry is a bit misleading. This commit renames it to
"Registry credentials" to make it more clear that it's just about
credentials for registries, not about adding registries.
2026-04-08 15:35:35 +03:00
Aidan TimsonandGitHub aab2304d86 Remove extra "Community:" prefix for add badge dialog (#51465)
Remove extra "Custom:" naming
2026-04-08 10:35:01 +00:00
Aidan TimsonandGitHub 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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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
20d53a2659 Allow quick search for non-admins, while hiding inaccessible areas (#51456)
Co-authored-by: Wendelin <[email protected]>
2026-04-08 09:50:36 +02:00
6dbc38386c Add apps info page for non-HAOS installations (#30364)
Co-authored-by: Wendelin <[email protected]>
2026-04-08 07:02:47 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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
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 <[email protected]>

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

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-04-08 09:40:42 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Petar Petrov
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 <[email protected]>
2026-04-08 05:28:37 +00:00
b73ae60cea Fix media browser dialog window (#51423)
Co-authored-by: Wendelin <[email protected]>
2026-04-07 14:52:16 +00:00
Petar PetrovandGitHub cef35c6c23 Center energy dashboard bar charts on period midpoint (#30325) 2026-04-07 16:51:35 +02:00
Petar PetrovandGitHub 6b9685ec9f Fix time condition summary using "and" instead of "or" for midnight-crossing ranges (#51452) 2026-04-07 16:47:17 +02:00
Petar PetrovandGitHub fc9289dc05 Fix incorrect timezone in automation time trigger/condition descriptions (#51454) 2026-04-07 14:09:28 +00:00
Aidan TimsonandGitHub 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
1eda51ddbc Allow customizing initial map view with latitude, longitude, and zoom (#51444)
Co-authored-by: Claude <[email protected]>
2026-04-07 15:07:13 +01:00
WendelinandGitHub 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 PetrovandGitHub 2f73351c35 Fix dialog show animation broken by connectedCallback _open sync (#51450) 2026-04-07 12:18:16 +00:00
Petar PetrovandGitHub 44b442dc0e Fix toast race condition causing stuck notifications (#51447) 2026-04-07 13:13:18 +01:00
Aidan TimsonandGitHub 916731d0ee Restore custom wording for integrations (#51440) 2026-04-07 13:11:28 +01:00
Aidan TimsonandGitHub 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
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

Co-authored-by: Copilot <[email protected]>

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

Co-authored-by: Copilot <[email protected]>

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

Co-authored-by: Copilot <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* PR comments

* fix import

* fix import

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-04-07 13:18:53 +02:00
Aidan TimsonandGitHub 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 PetrovandGitHub ba2fef50d0 Preserve browser back/forward keyboard shortcuts in tab group (#51439) 2026-04-07 11:07:34 +01:00
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 <[email protected]>

* 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 <[email protected]>

* format

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-04-07 06:48:00 +00:00
Elias AxonovandGitHub ae3d6c77ca Fix iOS long-press context menu showing on images (#9549) (#51432) 2026-04-07 08:55:14 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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
iluvdataandGitHub 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 PetrovandGitHub 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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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 BrownandGitHub 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
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 <[email protected]>
2026-04-06 15:26:29 +03:00
Tom CarpenterandGitHub 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 TimsonandGitHub 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
WendelinandGitHub b5ff6a991d Migrate ha-textarea (#51377)
* Migrate ha-textarea

* Fix autogrow

* fix styles
2026-04-06 11:54:55 +03:00
Aidan TimsonandGitHub 28254ca0f2 Focus scrollable content on load for integrations (#51379) 2026-04-06 11:49:16 +03:00
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 <[email protected]>

* Feedback

* Feedback

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Tom Carpenter <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-04-06 06:36:59 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 09:32:30 +03:00
Niccolò BettoandGitHub 90057854c8 Fix code input dialog undefined value concatenation (#51399) 2026-04-06 06:07:23 +00:00
Simon LamonandGitHub 04c8c82966 Increase gauge thickness for accessibility reasons (#51382)
Increase thickness for accessability reasons
2026-04-06 08:59:55 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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 KragtenandGitHub b1a390789d Always add options object to triggers and conditions (#51394) 2026-04-03 12:59:58 +02:00
Trevin ChowandGitHub 76c0dd1f1f Guard against orphaned label references in device list (#51359) 2026-04-03 09:00:05 +00:00
Trevin ChowandGitHub 96dacfdeca Fix TypeError in Voice Assistants expose page with manual entity filters (#51357) 2026-04-03 10:51:44 +02:00
Petar PetrovandGitHub 5f28ed35d2 Only use inflight map for pending fragment translation loads (#51393) 2026-04-03 10:39:50 +02:00
Petar PetrovandGitHub edd7b4c3dc Fix fragment translation race condition returning stale localize (#51381) 2026-04-03 10:21:52 +02:00
cbea8bbf44 fix: null value error on ha-form-integer (#51385)
Co-authored-by: Bram Kragten <[email protected]>
2026-04-03 08:19:03 +00:00
Petar PetrovandGitHub 23a41e4384 Fix next_flow dialog closing immediately after rendering (#51369) 2026-04-03 10:14:21 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
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 HattonandGitHub 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
275 changed files with 7382 additions and 3698 deletions
+3
View File
@@ -3,6 +3,9 @@ contact_links:
- name: Request a feature for the UI / Dashboards
url: https://github.com/orgs/home-assistant/discussions
about: Request a new feature for the Home Assistant frontend.
- name: Discuss UI or UX design
url: https://github.com/OpenHomeFoundation/ux-design/discussions
about: Share design feedback and discuss visual or UX changes with the design team.
- name: Report a bug that is NOT related to the UI / Dashboards
url: https://github.com/home-assistant/core/issues
about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository.
+2 -2
View File
@@ -98,13 +98,13 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-bundle-stats
path: build/stats/*.json
if-no-files-found: error
- name: Upload frontend build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: hass_frontend/
+2 -2
View File
@@ -59,14 +59,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: translations
path: translations.tar.gz
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -2
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@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -58,7 +58,7 @@ jobs:
script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Add no-stale label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.issues.addLabels({
@@ -41,7 +41,7 @@ jobs:
if: github.event.issue.type.name == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issueAuthor = context.payload.issue.user.login;
+1 -1
View File
@@ -57,4 +57,4 @@ test/coverage/
# AI tooling
.claude
.cursor
.opencode
+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. |
+12 -4
View File
@@ -10,7 +10,7 @@ import "../../../../src/components/input/ha-input";
import "../../../../src/components/input/ha-input-copy";
import "../../../../src/components/input/ha-input-multi";
import "../../../../src/components/input/ha-input-search";
import { localizeContext } from "../../../../src/data/context";
import { internationalizationContext } from "../../../../src/data/context";
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.copy": "Copy",
@@ -26,11 +26,19 @@ const LOCALIZE_KEYS: Record<string, string> = {
export class DemoHaInput extends LitElement {
constructor() {
super();
// Provides localizeContext for ha-input-copy, ha-input-multi and ha-input-search
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
// eslint-disable-next-line no-new
new ContextProvider(this, {
context: localizeContext,
initialValue: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
context: internationalizationContext,
initialValue: {
localize: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
language: "en",
selectedLanguage: null,
locale: {} as any,
translationMetadata: {} as any,
loadBackendTranslation: (async () => (key: string) => key) as any,
loadFragmentTranslation: (async () => (key: string) => key) as any,
},
});
}
@@ -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;
}
}
@@ -0,0 +1,3 @@
---
title: Box shadow
---
+98
View File
@@ -0,0 +1,98 @@
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
const SHADOWS = ["s", "m", "l"] as const;
@customElement("demo-misc-box-shadow")
export class DemoMiscBoxShadow extends LitElement {
protected render() {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<h2>${mode}</h2>
<div class="grid">
${SHADOWS.map(
(size) => html`
<div
class="box"
style="box-shadow: var(--ha-box-shadow-${size})"
>
${size}
</div>
`
)}
</div>
</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;
flex-direction: row;
gap: 48px;
padding: 48px;
}
.light,
.dark {
flex: 1;
background-color: var(--primary-background-color);
border-radius: 16px;
padding: 32px;
}
h2 {
margin: 0 0 24px;
font-size: 18px;
font-weight: 500;
color: var(--primary-text-color);
text-transform: capitalize;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px;
}
.box {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
border-radius: 12px;
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-size: 16px;
font-weight: 500;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-misc-box-shadow": DemoMiscBoxShadow;
}
}
@@ -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",
],
+25 -29
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",
@@ -59,22 +59,14 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
"@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 +74,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 +94,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 +108,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.5",
"marked": "18.0.0",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -144,16 +136,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",
"@lokalise/node-api": "15.6.1",
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.59.0",
"@lokalise/node-api": "15.7.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",
@@ -169,16 +163,17 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.2",
"@vitest/coverage-v8": "4.1.4",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"browserslist-useragent-regexp": "4.1.4",
"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,13 +181,14 @@
"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",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.0.1",
"jsdom": "29.0.2",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
@@ -200,17 +196,17 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.1",
"prettier": "3.8.2",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.0.3",
"sinon": "21.1.0",
"tar": "7.5.13",
"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.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.2",
"vitest": "4.1.4",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
@@ -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 {
+82 -4
View File
@@ -18,15 +18,16 @@ import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
import { transform } from "../../common/decorators/transform";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { themesContext } from "../../data/context";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
@@ -74,8 +75,11 @@ export class HaChartBase extends LitElement {
public extraComponents?: any[];
@state()
@consume({ context: themesContext, subscribe: true })
_themes!: Themes;
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Themes>({
transformer: ({ themes }) => themes,
})
private _themes!: Themes;
@state() private _isZoomed = false;
@@ -174,6 +178,7 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this._updateSankeyRoam();
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
@@ -192,6 +197,7 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this._updateSankeyRoam();
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
@@ -267,6 +273,9 @@ export class HaChartBase extends LitElement {
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series) {
this._updateSankeyRoam();
}
}
}
@@ -451,6 +460,22 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.on("sankeyroam", () => {
const option = this.chart!.getOption();
const series = option.series as any[];
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = sankeySeries.zoom !== 1;
this._isZoomed = zoomed;
if (!zoomed) {
// Reset center when fully zoomed out
this.chart!.setOption({
series: [{ id: sankeySeries.id, center: null }],
});
}
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
this.chart!.dispatchAction({ type: "downplay" });
});
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
@@ -549,6 +574,7 @@ export class HaChartBase extends LitElement {
...this._createOptions(),
series: this._getSeries(),
});
this._updateSankeyRoam();
} finally {
this._loading = false;
}
@@ -988,6 +1014,26 @@ export class HaChartBase extends LitElement {
if (!this.chart) {
return;
}
// Handle sankey chart double-click zoom
const option = this.chart.getOption();
const allSeries = option.series as any[];
const sankeySeries = allSeries?.filter((s: any) => s.type === "sankey");
if (sankeySeries?.length) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
zoom: 2,
})),
});
this._isZoomed = true;
}
if (sankeySeries.length === allSeries?.length) {
return;
}
}
const range = this._isZoomed
? [0, 100]
: [
@@ -1012,6 +1058,37 @@ export class HaChartBase extends LitElement {
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
// Reset sankey roam zoom
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
zoom: 1,
center: null,
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
}
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
roam: this._modifierPressed || this._isTouchDevice ? true : "move",
})),
});
}
}
private _handleDataZoomEvent(e: any) {
@@ -1382,5 +1459,6 @@ declare global {
start: number;
end: number;
};
"chart-sankeyroam": { zoom: number };
}
}
+11 -2
View File
@@ -64,6 +64,8 @@ export class HaSankeyChart extends LitElement {
public chart?: EChartsType;
private _currentZoom = 1;
@state() private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect,
});
@@ -84,11 +86,13 @@ export class HaSankeyChart extends LitElement {
} as ECOption;
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._createData(this.data, this._sizeController.value?.width)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@chart-click=${this._handleChartClick}
@chart-sankeyroam=${this._handleChartSankeyRoam}
></ha-chart-base>`;
}
@@ -109,6 +113,10 @@ export class HaSankeyChart extends LitElement {
return null;
};
private _handleChartSankeyRoam = (ev: CustomEvent) => {
this._currentZoom = ev.detail.zoom;
};
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
const detail = ev.detail;
// Only handle node clicks (not links)
@@ -180,6 +188,7 @@ export class HaSankeyChart extends LitElement {
})),
links,
draggable: false,
scaleLimit: { min: 1, max: 4 },
orient: this.vertical ? "vertical" : "horizontal",
nodeWidth: 15,
nodeGap: NODE_GAP,
@@ -210,7 +219,7 @@ export class HaSankeyChart extends LitElement {
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
const availableWidth = params.rect.width + 6;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
const fontSize = Math.min(
FONT_SIZE,
(availableWidth / wordWidth) * FONT_SIZE
@@ -223,7 +232,7 @@ export class HaSankeyChart extends LitElement {
};
}
const availableHeight = params.rect.height + 8; // account for the margin
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE
+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);
}
`;
}
+87 -44
View File
@@ -16,13 +16,18 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status";
import { localeContext, localizeContext } from "../../data/context";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
@@ -103,16 +108,13 @@ 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 {
@state()
@consume({ context: localizeContext, subscribe: true })
private _localize?: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private _locale?: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean }) public narrow = false;
@@ -166,6 +168,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 +248,30 @@ 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");
}
const activeElement = deepActiveElement();
if (
changedProps.has("selectable") ||
(!this.autoHeight &&
activeElement &&
AUTO_FOCUS_ALLOWED_ACTIVE_TAGS.includes(activeElement.tagName))
) {
this._focusScroller();
}
}
public willUpdate(properties: PropertyValues) {
@@ -507,7 +527,9 @@ export class HaDataTable extends LitElement {
<div class="mdc-data-table__row" role="row">
<div class="mdc-data-table__cell grows center" role="cell">
${this.noDataText ||
this._localize?.("ui.components.data-table.no-data") ||
this._i18n?.localize?.(
"ui.components.data-table.no-data"
) ||
"No data"}
</div>
</div>
@@ -517,11 +539,12 @@ 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,
this._localize,
this._locale,
this._i18n?.localize,
this._i18n?.locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -691,7 +714,7 @@ export class HaDataTable extends LitElement {
this._sortColumns[this.sortColumn],
this.sortDirection,
this.sortColumn,
this._locale?.language
this._i18n?.locale?.language
)
: filteredData;
@@ -829,8 +852,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 +873,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,14 +887,15 @@ 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,
this._localize,
this._locale,
this._i18n?.localize,
this._i18n?.locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -900,7 +927,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 +965,9 @@ export class HaDataTable extends LitElement {
return checkedRows;
}
private _handleRowClick = (ev: Event) => {
private _handleRowClick = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { rowId: string }>
) => {
if (
ev
.composedPath()
@@ -954,14 +983,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 +1011,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 +1026,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 +1469,11 @@ export class HaDataTable extends LitElement {
contain: size layout !important;
overscroll-behavior: contain;
}
lit-virtualizer:focus,
lit-virtualizer:focus-visible {
outline: none;
}
`,
];
}
+61 -34
View File
@@ -3,6 +3,7 @@ import { consume, type ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
@@ -12,16 +13,13 @@ import {
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { configContext, internationalizationContext } from "../../data/context";
import { TimeZone } from "../../data/translation";
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
import { haStyleScrollbar } from "../../resources/styles";
import type { ValueChangedEvent } from "../../types";
import type { HomeAssistantConfig, ValueChangedEvent } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-filter-chip";
import type { HaFilterChip } from "../chips/ha-filter-chip";
@@ -48,16 +46,15 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
public timePicker = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private hassConfig!: ContextType<typeof configContext>;
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
@@ -87,12 +84,20 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
? formatCallyDateRange(
this.startDate,
this.endDate,
this.locale,
this.hassConfig
this._i18n?.locale,
this._hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
if (this.timePicker && this.startDate && this.endDate) {
this._timeValue = {
@@ -144,12 +149,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<div class="range">
<calendar-range
.value=${this._dateValue}
.locale=${this.locale.language}
.locale=${this._i18n.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this.locale)}
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
>
<ha-icon-button-prev
tabindex="-1"
@@ -162,7 +167,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
.label=${this.localize("ui.dialogs.date-picker.today")}
.label=${this._i18n.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next
@@ -176,9 +181,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<div class="times">
<ha-time-input
.value=${`${this._timeValue.from.hours}:${this._timeValue.from.minutes}`}
.locale=${this.locale}
.locale=${this._i18n.locale}
@value-changed=${this._handleChangeTime}
.label=${this.localize(
.label=${this._i18n.localize(
"ui.components.date-range-picker.time_from"
)}
id="from"
@@ -187,9 +192,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
.locale=${this.locale}
.locale=${this._i18n.locale}
@value-changed=${this._handleChangeTime}
.label=${this.localize(
.label=${this._i18n.localize(
"ui.components.date-range-picker.time_to"
)}
id="to"
@@ -203,19 +208,33 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
</div>
<div class="footer">
<ha-button appearance="plain" @click=${this._cancel}
>${this.localize("ui.common.cancel")}</ha-button
>${this._i18n.localize("ui.common.cancel")}</ha-button
>
<ha-button .disabled=${!this._dateValue} @click=${this._save}
>${this.localize("ui.components.date-range-picker.select")}</ha-button
>${this._i18n.localize(
"ui.components.date-range-picker.select"
)}</ha-button
>
</div>`;
}
private _focusToday() {
const date = new Date();
this._focusDate = formatISODateOnly(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._focusDate = formatISODateOnly(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
}
private _cancel() {
@@ -255,12 +274,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
}
if (this.locale.time_zone === TimeZone.server) {
if (this._i18n.locale.time_zone === TimeZone.server) {
startDate = new Date(
new TZDate(startDate, this.hassConfig.time_zone).getTime()
new TZDate(startDate, this._hassConfig.time_zone).getTime()
);
endDate = new Date(
new TZDate(endDate, this.hassConfig.time_zone).getTime()
new TZDate(endDate, this._hassConfig.time_zone).getTime()
);
}
@@ -286,8 +305,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
}
@@ -3,6 +3,7 @@ import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -14,12 +15,10 @@ import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { configContext, internationalizationContext } from "../../data/context";
import type { HomeAssistantConfig } from "../../types";
import "../ha-bottom-sheet";
import "../ha-icon-button";
import "../ha-icon-button-next";
@@ -43,16 +42,15 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private hassConfig!: ContextType<typeof configContext>;
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
@property({ attribute: false }) public startDate!: Date;
@@ -117,8 +115,8 @@ export class HaDateRangePicker extends LitElement {
this._ranges = {};
rangeKeys.forEach((key) => {
this._ranges![
this.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this.locale, this.hassConfig, key);
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
});
}
@@ -133,47 +131,50 @@ 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)
? formatShortDateTime(
this.startDate,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
))}
.label=${this.localize(
.label=${this._i18n.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this.localize("ui.components.date-range-picker.end_date")}
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this.localize("ui.common.previous")}
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this.localize("ui.common.next")}
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
@@ -181,7 +182,7 @@ export class HaDateRangePicker extends LitElement {
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this.localize(
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
@@ -289,8 +290,8 @@ export class HaDateRangePicker extends LitElement {
this.startDate,
this.endDate,
forward,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
);
this.startDate = start;
this.endDate = end;
@@ -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);
}
}
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume, type ContextType } from "@lit/context";
import { mdiBackspace, mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
@@ -10,12 +11,10 @@ import {
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { transform } from "../../common/decorators/transform";
import { configContext, internationalizationContext } from "../../data/context";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { HomeAssistantConfig } from "../../types";
import "../ha-button";
import type { DatePickerDialogParams } from "../ha-date-input";
import "../ha-dialog";
@@ -40,16 +39,15 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
LitElement
) {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private hassConfig!: ContextType<typeof configContext>;
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
@state() private _value?: {
year: string;
@@ -74,14 +72,26 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? new Date(`${this.params.value.split("T")[0]}T00:00:00`)
: new Date();
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._value = this.params.value
? {
year: this._pickerYear,
title: formatDateShort(date, this.locale, this.hassConfig),
dateString: formatISODateOnly(date, this.locale, this.hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
this._i18n.locale,
this._hassConfig
),
}
: undefined;
}
@@ -95,7 +105,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
open
width="small"
.headerTitle=${this._value?.title ||
this.localize("ui.dialogs.date-picker.title")}
this._i18n.localize("ui.dialogs.date-picker.title")}
.headerSubtitle=${this._value?.year}
header-subtitle-position="above"
>
@@ -103,7 +113,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? html`
<ha-icon-button
.path=${mdiBackspace}
.label=${this.localize("ui.dialogs.date-picker.clear")}
.label=${this._i18n.localize("ui.dialogs.date-picker.clear")}
slot="headerActionItems"
@click=${this._clear}
></ha-icon-button>
@@ -131,7 +141,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
.label=${this.localize("ui.dialogs.date-picker.today")}
.label=${this._i18n.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next tabindex="-1" slot="next"></ha-icon-button-next>
@@ -143,10 +153,10 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.localize("ui.common.cancel")}
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._setValue}>
${this.localize("ui.common.ok")}
${this._i18n.localize("ui.common.ok")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>`;
@@ -164,23 +174,39 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? new Date(`${value.split("T")[0]}T00:00:00`)
: new Date();
this._value = {
year: formatDateYear(date, this.locale, this.hassConfig),
title: formatDateShort(date, this.locale, this.hassConfig),
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString:
value || formatISODateOnly(date, this.locale, this.hassConfig),
value || formatISODateOnly(date, this._i18n.locale, this._hassConfig),
};
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
}
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
}
@@ -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
+14 -12
View File
@@ -7,7 +7,7 @@ import memoizeOne from "memoize-one";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import { localizeContext } from "../data/context";
import { internationalizationContext } from "../data/context";
import type { UiColorExtraOption } from "../data/selector";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
@@ -55,8 +55,8 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
render() {
const effectiveValue = this.value ?? this.defaultColor ?? "";
@@ -73,7 +73,7 @@ export class HaColorPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
.notFoundLabel=${this.localize?.(
.notFoundLabel=${this._i18n?.localize?.(
"ui.components.color-picker.no_colors_found"
)}
.getAdditionalItems=${this._getAdditionalItems}
@@ -103,7 +103,7 @@ export class HaColorPicker extends LitElement {
{
id: searchString,
primary:
this.localize?.("ui.components.color-picker.custom_color") ||
this._i18n?.localize?.("ui.components.color-picker.custom_color") ||
"Custom color",
secondary: searchString,
},
@@ -130,14 +130,15 @@ export class HaColorPicker extends LitElement {
const items: PickerComboBoxItem[] = [];
const defaultSuffix =
this.localize?.("ui.components.color-picker.default") || "Default";
this._i18n?.localize?.("ui.components.color-picker.default") ||
"Default";
const addDefaultSuffix = (label: string, isDefault: boolean) =>
isDefault && defaultSuffix ? `${label} (${defaultSuffix})` : label;
if (includeNone) {
const noneLabel =
this.localize?.("ui.components.color-picker.none") || "None";
this._i18n?.localize?.("ui.components.color-picker.none") || "None";
items.push({
id: "none",
primary: addDefaultSuffix(noneLabel, defaultColor === "none"),
@@ -147,7 +148,7 @@ export class HaColorPicker extends LitElement {
if (includeState) {
const stateLabel =
this.localize?.("ui.components.color-picker.state") || "State";
this._i18n?.localize?.("ui.components.color-picker.state") || "State";
items.push({
id: "state",
primary: addDefaultSuffix(stateLabel, defaultColor === "state"),
@@ -170,7 +171,7 @@ export class HaColorPicker extends LitElement {
Array.from(THEME_COLORS).forEach((color) => {
const themeLabel =
this.localize?.(
this._i18n?.localize?.(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color;
items.push({
@@ -227,7 +228,7 @@ export class HaColorPicker extends LitElement {
return html`
<ha-svg-icon slot="start" .path=${mdiInvertColorsOff}></ha-svg-icon>
<span slot="headline">
${this.localize?.("ui.components.color-picker.none") || "None"}
${this._i18n?.localize?.("ui.components.color-picker.none") || "None"}
</span>
`;
}
@@ -235,7 +236,8 @@ export class HaColorPicker extends LitElement {
return html`
<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>
<span slot="headline">
${this.localize?.("ui.components.color-picker.state") || "State"}
${this._i18n?.localize?.("ui.components.color-picker.state") ||
"State"}
</span>
`;
}
@@ -243,7 +245,7 @@ export class HaColorPicker extends LitElement {
const extraOption = this.extraOptions?.find((o) => o.value === value);
const label =
extraOption?.label ||
this.localize?.(
this._i18n?.localize?.(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) ||
value;
+8 -8
View File
@@ -14,7 +14,7 @@ import { ifDefined } from "lit/directives/if-defined";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { withViewTransition } from "../common/util/view-transition";
import { localizeContext } from "../data/context";
import { internationalizationContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-dialog-header";
@@ -123,13 +123,13 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@query(".body") public bodyContainer!: HTMLDivElement;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
// @consume({ context: configContext, subscribe: true })
// private _hassConfig?: ContextType<typeof configContext>;
@state()
private _bodyScrolled = false;
@@ -184,7 +184,7 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
.label=${this.localize?.("ui.common.close") ?? "Close"}
.label=${this._i18n?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
@@ -222,13 +222,13 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
requestAnimationFrame(() => {
// disabled till iOS app fix the "focus_element" implementation
// if (this.auth?.external && isIosApp(this.auth.external)) {
// if (this._hassConfig?.auth.external && isIosApp(this._hassConfig.auth.external)) {
// const element = this.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-dialog-autofocus";
// }
// this.auth.external.fireMessage({
// this._hassConfig.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
+7 -16
View File
@@ -2,12 +2,7 @@ import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import {
authContext,
configContext,
connectionContext,
themesContext,
} from "../data/context";
import { configContext, connectionContext, uiContext } from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
domainIcon,
@@ -38,12 +33,8 @@ export class HaDomainIcon extends LitElement {
private _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: themesContext, subscribe: true })
private _themes?: ContextType<typeof themesContext>;
@state()
@consume({ context: authContext, subscribe: true })
private _auth?: ContextType<typeof authContext>;
@consume({ context: uiContext, subscribe: true })
private _hassUi?: ContextType<typeof uiContext>;
protected render() {
if (this.icon) {
@@ -59,8 +50,8 @@ export class HaDomainIcon extends LitElement {
}
const icon = domainIcon(
this._connection,
this._hassConfig,
this._connection.connection,
this._hassConfig.config,
this.domain,
this.deviceClass,
this.state
@@ -86,9 +77,9 @@ export class HaDomainIcon extends LitElement {
{
domain: this.domain!,
type: "icon",
darkOptimized: this._themes?.darkMode,
darkOptimized: this._hassUi?.themes.darkMode,
},
this._auth?.data.hassUrl
this._hassConfig?.auth.data.hassUrl
);
return html`
<img
-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 -1
View File
@@ -100,7 +100,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
inputMode="numeric"
.label=${this.label}
.hint=${this.helper}
.value=${this.data !== undefined ? 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";
+3 -18
View File
@@ -1,5 +1,5 @@
import Fuse from "fuse.js";
import { mdiDevices, mdiPlus, mdiTextureBox } from "@mdi/js";
import { mdiDevices, mdiTextureBox } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -8,7 +8,7 @@ import { caseInsensitiveStringCompare } from "../common/string/compare";
import { titleCase } from "../common/string/title-case";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { fetchConfig } from "../data/lovelace/config/types";
import { getPanelIcon, getPanelTitle } from "../data/panel";
import { getPanelIcon, getPanelTitle, SYSTEM_PANELS } from "../data/panel";
import { findRelated, type RelatedResult } from "../data/search";
import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards";
import { computeAreaPath } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
@@ -235,22 +235,6 @@ export class HaNavigationPicker extends LitElement {
addGroup("views", views);
addGroup("other_routes", otherRoutes);
if (
searchString &&
!this._navigationItems.some((navItem) => navItem.id === searchString)
) {
items.push({
id: searchString,
primary: this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
),
secondary: `"${searchString}"`,
icon_path: mdiPlus,
sorting_label: searchString,
group: "other_routes",
});
}
return items;
};
@@ -290,6 +274,7 @@ export class HaNavigationPicker extends LitElement {
const otherRoutes: NavigationItem[] = [];
for (const panel of panels) {
if (SYSTEM_PANELS.includes(panel.id)) continue;
const path = `/${panel.url_path}`;
const panelTitle = getPanelTitle(this.hass, panel);
const primary = panelTitle || path;
+10 -14
View File
@@ -15,7 +15,7 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { localeContext, localizeContext } from "../data/context";
import { internationalizationContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import {
multiTermSortedSearch,
@@ -162,12 +162,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private i18n!: ContextType<typeof internationalizationContext>;
@state() private _items: PickerComboBoxItem[] = [];
@@ -222,9 +218,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
const searchLabel =
this.label ??
(this.allowCustomValue
? (this.localize?.("ui.components.combo-box.search_or_custom") ??
? (this.i18n.localize?.("ui.components.combo-box.search_or_custom") ??
"Search | Add custom value")
: (this.localize?.("ui.common.search") ?? "Search"));
: (this.i18n.localize?.("ui.common.search") ?? "Search"));
return html`<ha-input-search
appearance="outlined"
@@ -351,7 +347,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return caseInsensitiveStringCompare(
sortLabelA,
sortLabelB,
this.locale?.language ?? navigator.language
this.i18n.locale?.language ?? navigator.language
);
});
}
@@ -368,7 +364,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
id: this._search,
primary:
this.customValueLabel ??
this.localize?.("ui.components.combo-box.add_custom_item") ??
this.i18n.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${this._search}"`,
icon_path: mdiPlus,
@@ -402,10 +398,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
? typeof this.notFoundLabel === "function"
? this.notFoundLabel(this._search)
: this.notFoundLabel ||
this.localize?.("ui.components.combo-box.no_match") ||
this.i18n.localize?.("ui.components.combo-box.no_match") ||
"No matching items found"
: this.emptyLabel ||
this.localize?.("ui.components.combo-box.no_items") ||
this.i18n.localize?.("ui.components.combo-box.no_items") ||
"No items available"}</span
>
</ha-combo-box-item>
@@ -497,7 +493,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
id: searchString,
primary:
this.customValueLabel ??
this.localize?.("ui.components.combo-box.add_custom_item") ??
this.i18n.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${searchString}"`,
icon_path: mdiPlus,
+5 -6
View File
@@ -1,4 +1,4 @@
import { consume } from "@lit/context";
import { consume, type ContextType } from "@lit/context";
import { mdiClose, mdiMenuDown } from "@mdi/js";
import {
css,
@@ -11,9 +11,8 @@ import {
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { localizeContext } from "../data/context";
import { internationalizationContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon";
@@ -34,8 +33,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: HomeAssistant["localize"];
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
public async focus() {
await this.updateComplete;
@@ -89,7 +88,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
${this.unknown
? html`<div slot="supporting-text" class="unknown">
${this.unknownItemText ||
this.localize("ui.components.combo-box.unknown_item")}
this._i18n?.localize("ui.components.combo-box.unknown_item")}
</div>`
: nothing}
${showClearIcon
@@ -93,6 +93,7 @@ export class HaSelectSelector extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value as string | undefined}
.disabled=${this.disabled}
@value-changed=${this._selectChanged}
.maxColumns=${this.selector.select?.box_max_columns}
.hass=${this.hass}
@@ -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;
+25 -31
View File
@@ -1,12 +1,10 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -200,7 +198,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
return html`
<div class="mdc-chip-set items">
<div class="items">
${floorIds.length
? floorIds.map(
(floor_id) => html`
@@ -1233,34 +1231,30 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this.hass?.locale.language ?? navigator.language
);
static get styles(): CSSResultGroup {
return css`
.add-target-wrapper {
display: flex;
justify-content: flex-start;
margin-top: var(--ha-space-3);
}
static styles = css`
.add-target-wrapper {
display: flex;
justify-content: flex-start;
margin-top: var(--ha-space-3);
}
ha-generic-picker {
width: 100%;
}
ha-generic-picker {
width: 100%;
}
${unsafeCSS(chipStyles)}
.items {
z-index: 2;
}
.mdc-chip-set {
padding: var(--ha-space-1) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
}
.items {
z-index: 2;
display: flex;
flex-wrap: wrap;
padding: var(--ha-space-2) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
}
declare global {
+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);
}
`,
];
@@ -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";
+6 -6
View File
@@ -3,7 +3,7 @@ import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import { localizeContext } from "../../data/context";
import { internationalizationContext } from "../../data/context";
import { showToast } from "../../util/toast";
import "../ha-button";
import "../ha-icon-button";
@@ -59,8 +59,8 @@ export class HaInputCopy extends LitElement {
false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _showMasked = true;
@@ -90,7 +90,7 @@ export class HaInputCopy extends LitElement {
? html`<ha-icon-button
slot="end"
class="toggle-unmasked"
.label=${this.localize(
.label=${this._i18n.localize(
`ui.common.${this._showMasked ? "show" : "hide"}`
)}
@click=${this._toggleMasked}
@@ -101,7 +101,7 @@ export class HaInputCopy extends LitElement {
</div>
<ha-button @click=${this._copy} appearance="plain" size="small">
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
${this.label || this.localize("ui.common.copy")}
${this.label || this._i18n.localize("ui.common.copy")}
</ha-button>
</div>
`;
@@ -119,7 +119,7 @@ export class HaInputCopy extends LitElement {
private async _copy(): Promise<void> {
await copyToClipboard(this.value);
showToast(this, {
message: this.localize("ui.common.copied_clipboard"),
message: this._i18n.localize("ui.common.copied_clipboard"),
});
}
+6 -6
View File
@@ -5,7 +5,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { localizeContext } from "../../data/context";
import { internationalizationContext } from "../../data/context";
import { haStyle } from "../../resources/styles";
import "../ha-button";
import "../ha-icon-button";
@@ -64,8 +64,8 @@ class HaInputMulti extends LitElement {
public updateOnBlur = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize?: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
protected render() {
return html`
@@ -109,7 +109,7 @@ class HaInputMulti extends LitElement {
.index=${index}
slot="navigationIcon"
.label=${this.removeLabel ??
this.localize?.("ui.common.remove") ??
this._i18n?.localize("ui.common.remove") ??
"Remove"}
@click=${this._removeItem}
.path=${mdiDeleteOutline}
@@ -137,10 +137,10 @@ class HaInputMulti extends LitElement {
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.addLabel ??
(this.label
? this.localize?.("ui.components.multi-textfield.add_item", {
? this._i18n?.localize("ui.components.multi-textfield.add_item", {
item: this.label,
})
: this.localize?.("ui.common.add")) ??
: this._i18n?.localize("ui.common.add")) ??
"Add"}
</ha-button>
</div>
+5 -5
View File
@@ -2,7 +2,7 @@ import { consume, type ContextType } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import { html, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import { localizeContext } from "../../data/context";
import { internationalizationContext } from "../../data/context";
import { HaInput } from "./ha-input";
/**
@@ -18,8 +18,8 @@ import { HaInput } from "./ha-input";
@customElement("ha-input-search")
export class HaInputSearch extends HaInput {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
constructor() {
super();
@@ -33,9 +33,9 @@ export class HaInputSearch extends HaInput {
if (
!this.label &&
!this.placeholder &&
(!this.hasUpdated || changedProps.has("localize"))
(!this.hasUpdated || changedProps.has("_i18n"))
) {
this.placeholder = this.localize("ui.common.search");
this.placeholder = this._i18n.localize("ui.common.search");
}
}
+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) {
@@ -115,11 +115,11 @@ class DialogMediaManage extends LitElement {
></ha-icon-button
></slot>`
: nothing}
<span class="title" slot="title" id="dialog-box-title">
<h1 class="title" slot="title" id="dialog-box-title">
${this.hass.localize(
"ui.components.media-browser.file_management.title"
)}
</span>
</h1>
${this._selected.size === 0
? html`<ha-media-upload-button
.hass=${this.hass}
@@ -366,6 +366,11 @@ class DialogMediaManage extends LitElement {
color: var(--secondary-text-color);
font-style: italic;
}
.title {
font: inherit;
font-size: inherit;
margin: inherit;
}
`,
];
}
@@ -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 {
+131
View File
@@ -0,0 +1,131 @@
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-indicator-background - Background of the filled progress indicator. Overrides `--ha-progress-bar-indicator-color` when set (accepts any CSS background value, e.g. gradients).
* @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`.
* @cssprop --ha-progress-bar-indicator-highlight-image - Image shown at the progress indicator tip (accepts any CSS `background-image` value). Hidden during indeterminate state.
* @cssprop --ha-progress-bar-indicator-highlight-width - Width of the indicator highlight element. Defaults to `calc(var(--track-height) * 2)`.
* @cssprop --ha-progress-bar-indicator-highlight-height - Height of the indicator highlight element. Defaults to `calc(var(--track-height) * 2)`.
*
* @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);
position: relative;
}
.progress-bar {
border-radius: var(
--ha-progress-bar-border-radius,
var(--ha-border-radius-pill)
);
}
.indicator {
background: var(
--ha-progress-bar-indicator-background,
var(--indicator-color)
);
}
:host([indeterminate]) .indicator {
animation: wa-progress-indeterminate
var(--ha-progress-bar-animation-duration, 2.5s) infinite
cubic-bezier(0.37, 0, 0.63, 1);
}
@keyframes slide-highlight {
0% {
transform: translateX(-200%);
}
100% {
transform: translateX(200%);
}
}
:host([loading]:not([indeterminate])) .progress-bar::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
oklch(from var(--indicator-color) 85% c h) 50%,
transparent 100%
);
opacity: 0.5;
animation: slide-highlight
var(--ha-progress-bar-animation-duration, 2.5s) infinite
cubic-bezier(0.37, 0, 0.63, 1);
width: 50%;
}
:host::after {
--width: var(
--ha-progress-bar-indicator-highlight-width,
calc(var(--track-height) * 2)
);
width: var(--width);
height: var(
--ha-progress-bar-indicator-highlight-height,
calc(var(--track-height) * 2)
);
content: "";
position: absolute;
inset-inline-start: clamp(
var(--width) / 2,
var(--percentage, 0%),
calc(100% - var(--width) / 2)
);
top: 50%;
transform: translate(-50%, -50%);
background-image: var(--ha-progress-bar-indicator-highlight-image);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
pointer-events: none;
transition: inset-inline-start var(--wa-transition-slow, 200ms)
var(--wa-transition-easing, ease);
}
:host([indeterminate])::after {
display: none;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-progress-bar": HaProgressBar;
}
}
@@ -1,17 +1,14 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import {
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
@@ -27,6 +24,7 @@ import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-domain-icon";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
@@ -56,50 +54,37 @@ export class HaTargetPickerValueChip extends LitElement {
this._itemData(this.type, this.itemId);
return html`
<div
class="mdc-chip ${classMap({
[this.type]: true,
})}"
style=${color
? `--color: rgb(${color}); --background-color: rgba(${color}, .5)`
: ""}
<wa-tag
pill
with-remove
class=${this.type}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
${iconPath
? html`<ha-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.icon=${iconPath}
></ha-icon>`
: this._iconImg
? html`<img
class="mdc-chip__icon mdc-chip__icon--leading"
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span id="title-${this.itemId}" class="mdc-chip__text"
>${name}</span
>
</span>
</span>
<div class="icon">
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
</div>
<span class="name"> ${name} </span>
${this.type === "entity"
? nothing
: html`<span role="gridcell">
<ha-tooltip .for="expand-${slugify(this.itemId)}"
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
@@ -114,25 +99,8 @@ export class HaTargetPickerValueChip extends LitElement {
.id="expand-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>
</span>`}
<span role="gridcell">
<ha-tooltip .for="remove-${slugify(this.itemId)}">
${this.hass.localize(
`ui.components.target-picker.remove_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize("ui.components.target-picker.remove")}
.path=${mdiClose}
hide-title
.id="remove-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._removeItem}
></ha-icon-button>
</span>
</div>
></ha-icon-button>`}
</wa-tag>
`;
}
@@ -241,101 +209,52 @@ export class HaTargetPickerValueChip extends LitElement {
}
static styles = css`
${unsafeCSS(chipStyles)}
.mdc-chip {
:host {
display: inline-block;
max-width: 100%;
}
wa-tag {
background-color: var(--card-background-color);
border-width: var(--ha-border-width-md);
padding-inline-start: 0;
overflow: hidden;
max-width: 100%;
color: var(--primary-text-color);
}
.mdc-chip.add {
color: rgba(0, 0, 0, 0.87);
wa-tag.entity {
border-color: var(--ha-color-green-80);
--background-color: var(--ha-color-green-80);
}
.add-container {
position: relative;
display: inline-flex;
wa-tag.device {
border-color: var(--ha-color-primary-80);
--background-color: var(--ha-color-primary-80);
}
.mdc-chip:not(.add) {
cursor: default;
wa-tag.area {
border-color: var(--ha-color-orange-80);
--background-color: var(--ha-color-orange-80);
}
.mdc-chip ha-icon-button {
--ha-icon-button-size: 24px;
display: flex;
align-items: center;
outline: none;
wa-tag.label {
border-color: var(--color);
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
.mdc-chip ha-icon-button ha-svg-icon {
border-radius: 50%;
background: var(--secondary-text-color);
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mdc-chip__icon.mdc-chip__icon--trailing {
width: var(--ha-space-4);
height: var(--ha-space-4);
--mdc-icon-size: 14px;
color: var(--secondary-text-color);
margin-inline-start: var(--ha-space-1) !important;
margin-inline-end: calc(-1 * var(--ha-space-1)) !important;
direction: var(--direction);
}
.mdc-chip__icon--leading {
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 20px;
.icon {
background-color: var(--background-color);
border-radius: var(--ha-border-radius-circle);
padding: 6px;
margin-left: -13px !important;
margin-inline-start: -13px !important;
margin-inline-end: var(--ha-space-1) !important;
direction: var(--direction);
padding: var(--ha-space-2) var(--ha-space-1);
display: flex;
}
.expand-btn {
margin-right: 0;
margin-inline-end: 0;
margin-inline-start: initial;
}
.mdc-chip.area:not(.add),
.mdc-chip.floor:not(.add) {
border: 1px solid #fed6a4;
background: var(--card-background-color);
}
.mdc-chip.area:not(.add) .mdc-chip__icon--leading,
.mdc-chip.area.add,
.mdc-chip.floor:not(.add) .mdc-chip__icon--leading,
.mdc-chip.floor.add {
background: #fed6a4;
}
.mdc-chip.device:not(.add) {
border: 1px solid #a8e1fb;
background: var(--card-background-color);
}
.mdc-chip.device:not(.add) .mdc-chip__icon--leading,
.mdc-chip.device.add {
background: #a8e1fb;
}
.mdc-chip.entity:not(.add) {
border: 1px solid #d2e7b9;
background: var(--card-background-color);
}
.mdc-chip.entity:not(.add) .mdc-chip__icon--leading,
.mdc-chip.entity.add {
background: #d2e7b9;
}
.mdc-chip.label:not(.add) {
border: 1px solid var(--color, #e0e0e0);
background: var(--card-background-color);
}
.mdc-chip.label:not(.add) .mdc-chip__icon--leading,
.mdc-chip.label.add {
background: var(--background-color, #e0e0e0);
}
.mdc-chip:hover {
z-index: 5;
}
:host([disabled]) .mdc-chip {
opacity: var(--light-disabled-opacity);
pointer-events: none;
}
.tooltip-icon-img {
width: 24px;
height: 24px;
--ha-icon-button-size: 16px;
--mdc-icon-size: 14px;
}
`;
}
@@ -5,6 +5,8 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
import { fullEntitiesContext, labelsContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -24,8 +26,6 @@ import "../ha-icon-button";
import "./hat-logbook-note";
import type { NodeInfo } from "./hat-script-graph";
import { traceTabStyles } from "./trace-tab-styles";
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
const TRACE_PATH_TABS = [
"step_config",
+57 -25
View File
@@ -1,6 +1,8 @@
import { TZDate } from "@date-fns/tz";
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import {
formatDurationDigital,
formatDurationLong,
formatNumericDuration,
} from "../common/datetime/format_duration";
@@ -68,8 +70,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);
@@ -857,6 +873,27 @@ const describeLegacyTrigger = (
return undefined;
};
const formatSunOffset = (
hass: HomeAssistant,
offset?: number | string | ForDict
): string => {
if (!offset) {
return "";
}
if (typeof offset === "number") {
return secondsToDuration(offset)!;
}
if (typeof offset === "string") {
return offset;
}
try {
const formatted = formatDurationDigital(hass.locale, offset);
return formatted.startsWith("-") ? formatted : `+${formatted}`;
} catch (_e) {
return JSON.stringify(offset);
}
};
export const describeCondition = (
condition: Condition,
hass: HomeAssistant,
@@ -1197,7 +1234,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) {
@@ -1220,30 +1267,15 @@ const describeLegacyCondition = (
// Sun condition
if (condition.condition === "sun" && (condition.before || condition.after)) {
let afterDuration = "";
if (condition.after && condition.after_offset) {
if (typeof condition.after_offset === "number") {
afterDuration = secondsToDuration(condition.after_offset)!;
} else if (typeof condition.after_offset === "string") {
afterDuration = condition.after_offset;
} else {
afterDuration = JSON.stringify(condition.after_offset);
}
}
let beforeDuration = "";
if (condition.before && condition.before_offset) {
if (typeof condition.before_offset === "number") {
beforeDuration = secondsToDuration(condition.before_offset)!;
} else if (typeof condition.before_offset === "string") {
beforeDuration = condition.before_offset;
} else {
beforeDuration = JSON.stringify(condition.before_offset);
}
}
const afterDuration = condition.after
? formatSunOffset(hass, condition.after_offset)
: "";
const beforeDuration = condition.before
? formatSunOffset(hass, condition.before_offset)
: "";
return hass.localize(
`${conditionsTranslationBaseKey}.sun.description.full`,
`${conditionsTranslationBaseKey}.sun.description.${condition.before && condition.after ? "between" : condition.before ? "before" : "after"}`,
{
afterChoice: condition.after ?? "other",
afterOffsetChoice: afterDuration !== "" ? "offset" : "other",
+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,
};
};
-38
View File
@@ -1,38 +0,0 @@
import { createContext } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { ConfigEntry } from "./config_entries";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import type { LabelRegistryEntry } from "./label/label_registry";
export const connectionContext =
createContext<HomeAssistant["connection"]>("connection");
export const statesContext = createContext<HomeAssistant["states"]>("states");
export const entitiesContext =
createContext<HomeAssistant["entities"]>("entities");
export const devicesContext =
createContext<HomeAssistant["devices"]>("devices");
export const areasContext = createContext<HomeAssistant["areas"]>("areas");
export const localizeContext =
createContext<HomeAssistant["localize"]>("localize");
export const localeContext = createContext<HomeAssistant["locale"]>("locale");
export const configContext = createContext<HassConfig>("config");
export const themesContext = createContext<HomeAssistant["themes"]>("themes");
export const selectedThemeContext =
createContext<HomeAssistant["selectedTheme"]>("selectedTheme");
export const userContext = createContext<HomeAssistant["user"]>("user");
export const userDataContext =
createContext<HomeAssistant["userData"]>("userData");
export const panelsContext = createContext<HomeAssistant["panels"]>("panels");
export const fullEntitiesContext =
createContext<EntityRegistryEntry[]>("extendedEntities");
export const floorsContext = createContext<HomeAssistant["floors"]>("floors");
export const labelsContext = createContext<LabelRegistryEntry[]>("labels");
export const configEntriesContext =
createContext<ConfigEntry[]>("configEntries");
export const authContext = createContext<HomeAssistant["auth"]>("auth");
+148
View File
@@ -0,0 +1,148 @@
import { createContext } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
HomeAssistantRegistries,
HomeAssistantUI,
} from "../../types";
import type { ConfigEntry } from "../config_entries";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import type { LabelRegistryEntry } from "../label/label_registry";
/**
* Entity, device, area, and floor registries
*/
export const registriesContext =
createContext<HomeAssistantRegistries>("hassRegistries");
/**
* Live map of all entity states, keyed by entity ID.
*/
export const statesContext = createContext<HomeAssistant["states"]>("states");
/**
* Provides the map of all available Home Assistant services, keyed by domain.
*/
export const servicesContext =
createContext<HomeAssistant["services"]>("services");
/**
* i18n state: active language, locale settings, the `localize` function, translation metadata, and the
* `loadBackendTranslation` / `loadFragmentTranslation` loaders.
*/
export const internationalizationContext =
createContext<HomeAssistantInternationalization>("hassInternationalization");
/**
* HTTP and WebSocket API surface: `callService`, `callApi`,
* `callApiRaw`, `callWS`, `sendWS`, `fetchWithAuth`, and `hassUrl`.
*/
export const apiContext = createContext<HomeAssistantApi>("hassApi");
/**
* WebSocket connection state: `connection`, `connected`, and `debugConnection`.
*/
export const connectionContext =
createContext<HomeAssistantConnection>("hassConnection");
/**
* UI preferences and global UI state: themes, selected theme,
* panels, sidebar mode, kiosk mode, shortcuts, vibration, and
* `suspendWhenHidden`.
*/
export const uiContext = createContext<HomeAssistantUI>("hassUi");
/**
* HA core configuration together with user session data:
* `auth`, `config` (core HA config), `user`, `userData`, and `systemData`.
*/
export const configContext = createContext<HomeAssistantConfig>("hassConfig");
/**
* Map of all entities in the entity registry, keyed by entity ID.
*/
export const entitiesContext =
createContext<HomeAssistant["entities"]>("entities");
/**
* Map of all devices in the device registry, keyed by device ID.
*/
export const devicesContext =
createContext<HomeAssistant["devices"]>("devices");
/**
* Map of all areas in the area registry, keyed by area ID.
*/
export const areasContext = createContext<HomeAssistant["areas"]>("areas");
/**
* Map of all floors in the floor registry, keyed by floor ID.
*/
export const floorsContext = createContext<HomeAssistant["floors"]>("floors");
// #region lazy-contexts
/**
* Lazy contexts are not subscribed to by default. They are only subscribed to when a provider is consumed with at least one consumer.
*/
/**
* Lazy loaded labels registry, keyed by label ID.
*/
export const labelsContext = createContext<LabelRegistryEntry[]>("labels");
/**
* Lazy loaded entity registry array
*/
export const fullEntitiesContext =
createContext<EntityRegistryEntry[]>("extendedEntities");
/**
* Lazy loaded config entries array
*/
export const configEntriesContext =
createContext<ConfigEntry[]>("configEntries");
// #endregion lazy-contexts
// #region deprecated-contexts
/** @deprecated Use `connectionContext` instead. */
export const connectionSingleContext =
createContext<HomeAssistant["connection"]>("connection");
/** @deprecated Use `internationalizationContext` instead. */
export const localizeContext =
createContext<HomeAssistant["localize"]>("localize");
/** @deprecated Use `internationalizationContext` instead. */
export const localeContext = createContext<HomeAssistant["locale"]>("locale");
/** @deprecated Use `configContext` instead. */
export const configSingleContext = createContext<HassConfig>("config");
/** @deprecated Use `uiContext` instead. */
export const themesContext = createContext<HomeAssistant["themes"]>("themes");
/** @deprecated Use `uiContext` instead. */
export const selectedThemeContext =
createContext<HomeAssistant["selectedTheme"]>("selectedTheme");
/** @deprecated Use `configContext` instead. */
export const userContext = createContext<HomeAssistant["user"]>("user");
/** @deprecated Use `configContext` instead. */
export const userDataContext =
createContext<HomeAssistant["userData"]>("userData");
/** @deprecated Use `uiContext` instead. */
export const panelsContext = createContext<HomeAssistant["panels"]>("panels");
/** @deprecated Use `configContext` instead. */
export const authContext = createContext<HomeAssistant["auth"]>("auth");
// #endregion deprecated-contexts
+166
View File
@@ -0,0 +1,166 @@
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
HomeAssistantRegistries,
HomeAssistantUI,
} from "../../types";
const updateRegistries = (
hass: HomeAssistant,
value?: HomeAssistantRegistries
): HomeAssistantRegistries => {
if (
!value ||
value.entities !== hass.entities ||
value.devices !== hass.devices ||
value.areas !== hass.areas ||
value.floors !== hass.floors
) {
return {
entities: hass.entities,
devices: hass.devices,
areas: hass.areas,
floors: hass.floors,
};
}
return value;
};
const updateInternationalization = (
hass: HomeAssistant,
value?: HomeAssistantInternationalization
): HomeAssistantInternationalization => {
if (
!value ||
value.localize !== hass.localize ||
value.locale !== hass.locale ||
value.loadBackendTranslation !== hass.loadBackendTranslation ||
value.loadFragmentTranslation !== hass.loadFragmentTranslation ||
value.language !== hass.language ||
value.selectedLanguage !== hass.selectedLanguage ||
value.translationMetadata !== hass.translationMetadata
) {
return {
localize: hass.localize,
locale: hass.locale,
loadBackendTranslation: hass.loadBackendTranslation,
loadFragmentTranslation: hass.loadFragmentTranslation,
language: hass.language,
selectedLanguage: hass.selectedLanguage,
translationMetadata: hass.translationMetadata,
};
}
return value;
};
const updateApi = (
hass: HomeAssistant,
value?: HomeAssistantApi
): HomeAssistantApi => {
if (
!value ||
value.callService !== hass.callService ||
value.callApi !== hass.callApi ||
value.callApiRaw !== hass.callApiRaw ||
value.callWS !== hass.callWS ||
value.sendWS !== hass.sendWS ||
value.fetchWithAuth !== hass.fetchWithAuth
) {
return {
callService: hass.callService,
callApi: hass.callApi,
callApiRaw: hass.callApiRaw,
callWS: hass.callWS,
sendWS: hass.sendWS,
fetchWithAuth: hass.fetchWithAuth,
};
}
return value;
};
const updateConnection = (
hass: HomeAssistant,
value?: HomeAssistantConnection
): HomeAssistantConnection => {
if (
!value ||
value.connection !== hass.connection ||
value.connected !== hass.connected ||
value.debugConnection !== hass.debugConnection ||
value.hassUrl !== hass.hassUrl
) {
return {
connection: hass.connection,
connected: hass.connected,
debugConnection: hass.debugConnection,
hassUrl: hass.hassUrl,
};
}
return value;
};
const updateUi = (
hass: HomeAssistant,
value?: HomeAssistantUI
): HomeAssistantUI => {
if (
!value ||
value.themes !== hass.themes ||
value.selectedTheme !== hass.selectedTheme ||
value.panels !== hass.panels ||
value.panelUrl !== hass.panelUrl ||
value.dockedSidebar !== hass.dockedSidebar ||
value.kioskMode !== hass.kioskMode ||
value.enableShortcuts !== hass.enableShortcuts ||
value.vibrate !== hass.vibrate ||
value.suspendWhenHidden !== hass.suspendWhenHidden
) {
return {
themes: hass.themes,
selectedTheme: hass.selectedTheme,
panels: hass.panels,
panelUrl: hass.panelUrl,
dockedSidebar: hass.dockedSidebar,
kioskMode: hass.kioskMode,
enableShortcuts: hass.enableShortcuts,
vibrate: hass.vibrate,
suspendWhenHidden: hass.suspendWhenHidden,
};
}
return value;
};
const updateConfig = (
hass: HomeAssistant,
value?: HomeAssistantConfig
): HomeAssistantConfig => {
if (
!value ||
value.auth !== hass.auth ||
value.config !== hass.config ||
value.user !== hass.user ||
value.userData !== hass.userData ||
value.systemData !== hass.systemData
) {
return {
auth: hass.auth,
config: hass.config,
user: hass.user,
userData: hass.userData,
systemData: hass.systemData,
};
}
return value;
};
export const updateHassGroups = {
registries: updateRegistries,
internationalization: updateInternationalization,
api: updateApi,
connection: updateConnection,
ui: updateUi,
config: updateConfig,
};
+2
View File
@@ -1,4 +1,6 @@
export const isExternal =
window.externalAppV2 ||
window.externalApp ||
window.webkit?.messageHandlers?.getExternalAuth ||
location.search.includes("external_auth=1");
export const isExternalAndroid = window.externalApp || window.externalAppV2;
+2
View File
@@ -4,6 +4,7 @@ export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
}
export interface SidebarFrontendUserData {
@@ -21,6 +22,7 @@ export interface HomeFrontendSystemData {
favorite_entities?: string[];
welcome_banner_dismissed?: boolean;
hidden_summaries?: string[];
hide_welcome_message?: boolean;
}
declare global {
+1 -1
View File
@@ -32,7 +32,7 @@ export interface LovelaceViewBackgroundConfig {
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
layout?: "start" | "center" | "responsive" | "integrated";
badges_position?: "bottom" | "top";
badges_wrap?: "wrap" | "scroll";
}
+33
View File
@@ -0,0 +1,33 @@
import type { LovelaceStrategyConfigType } from "../panels/lovelace/strategies/get-strategy";
export interface CustomStrategyEntry {
type: string;
name?: string;
description?: string;
documentationURL?: string;
strategyType: LovelaceStrategyConfigType;
}
export interface CustomStrategiesWindow {
customStrategies?: CustomStrategyEntry[];
}
const customStrategiesWindow = window as CustomStrategiesWindow;
if (!("customStrategies" in customStrategiesWindow)) {
customStrategiesWindow.customStrategies = [];
}
export const customStrategies = customStrategiesWindow.customStrategies!;
export const getCustomStrategiesForType = (
strategyType: LovelaceStrategyConfigType
) => customStrategies.filter((s) => s.strategyType === strategyType);
export const getCustomStrategyEntry = (
type: string,
strategyType: LovelaceStrategyConfigType
) =>
customStrategies.find(
(s) => s.type === type && s.strategyType === strategyType
);
+5
View File
@@ -12,11 +12,16 @@ import type { LocalizeKeys } from "../common/translations/localize";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant, PanelInfo } from "../types";
export const APP_PANEL = "app";
export const HOME_PANEL = "home";
export const MY_REDIRECT_PANEL = "_my_redirect";
export const NOT_FOUND_PANEL = "notfound";
export const PROFILE_PANEL = "profile";
export const LOVELACE_PANEL = "lovelace";
/** Panels that are internal/system-level and should not appear in user-facing navigation UIs. */
export const SYSTEM_PANELS = [MY_REDIRECT_PANEL, NOT_FOUND_PANEL, APP_PANEL];
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = HOME_PANEL;
+6 -11
View File
@@ -18,7 +18,11 @@ import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
import { domainToName } from "./integration";
import { getPanelIcon, getPanelNameTranslationKey } from "./panel";
import {
getPanelIcon,
getPanelNameTranslationKey,
SYSTEM_PANELS,
} from "./panel";
export interface NavigationComboBoxItem extends PickerComboBoxItem {
path: string;
@@ -51,12 +55,7 @@ const generateNavigationPanelCommands = (
apps?: HassioAddonInfo[]
): BaseNavigationCommand[] =>
Object.entries(panels)
.filter(
([panelKey]) =>
panelKey !== "_my_redirect" &&
panelKey !== "hassio" &&
panelKey !== "app"
)
.filter(([panelKey]) => !SYSTEM_PANELS.includes(panelKey))
.map(([_panelKey, panel]) => {
const translationKey = getPanelNameTranslationKey(panel);
const icon = getPanelIcon(panel) || "mdi:view-dashboard";
@@ -105,10 +104,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;
};
@@ -578,8 +578,9 @@ class MoreInfoMediaPlayer extends LitElement {
.volume {
display: flex;
align-items: center;
justify-content: center;
gap: var(--ha-space-3);
margin-left: var(--ha-space-2);
padding-inline: var(--ha-space-2);
}
.volume-slider-container {
@@ -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;
}
`,
];
+13 -11
View File
@@ -5,7 +5,7 @@ import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-alert";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import { localizeContext } from "../../data/context";
import { internationalizationContext } from "../../data/context";
import { isMac } from "../../util/is_mac";
import { DialogMixin } from "../dialog-mixin";
@@ -168,8 +168,8 @@ const _SHORTCUTS: Section[] = [
@customElement("dialog-shortcuts")
class DialogShortcuts extends DialogMixin(LitElement) {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _renderShortcut(
shortcutKeys: ShortcutString[],
@@ -183,13 +183,15 @@ class DialogShortcuts extends DialogMixin(LitElement) {
>${shortcutKey === CTRL_CMD
? isMac
? "⌘"
: this.localize("ui.dialogs.shortcuts.keys.ctrl")
: this._i18n.localize("ui.dialogs.shortcuts.keys.ctrl")
: typeof shortcutKey === "string"
? shortcutKey
: this.localize(shortcutKey.shortcutTranslationKey)}</span
: this._i18n.localize(
shortcutKey.shortcutTranslationKey
)}</span
>`
)}
${this.localize(descriptionKey)}
${this._i18n.localize(descriptionKey)}
</div>
`;
}
@@ -198,12 +200,12 @@ class DialogShortcuts extends DialogMixin(LitElement) {
return html`
<ha-dialog
open
.headerTitle=${this.localize("ui.dialogs.shortcuts.title")}
.headerTitle=${this._i18n.localize("ui.dialogs.shortcuts.title")}
>
<div class="content">
${_SHORTCUTS.map(
(section) => html`
<h3>${this.localize(section.titleTranslationKey)}</h3>
<h3>${this._i18n.localize(section.titleTranslationKey)}</h3>
<div class="items">
${section.items.map((item) => {
if ("shortcut" in item) {
@@ -213,7 +215,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
);
}
return html`<p>
${this.localize((item as Text).textTranslationKey)}
${this._i18n.localize((item as Text).textTranslationKey)}
</p>`;
})}
</div>
@@ -222,9 +224,9 @@ class DialogShortcuts extends DialogMixin(LitElement) {
</div>
<ha-alert slot="footer">
${this.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
${this._i18n.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
user_profile: html`<a href="/profile/general#shortcuts"
>${this.localize(
>${this._i18n.localize(
"ui.dialogs.shortcuts.enable_shortcuts_hint_user_profile"
)}</a
>`,
@@ -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";
+21 -4
View File
@@ -5,6 +5,11 @@ import { Auth } from "home-assistant-js-websocket";
import type { EMMessage } from "./external_messaging";
import { ExternalMessaging } from "./external_messaging";
/**
* WARNING: These constants should not be changed, as the native app relies on
* these exact string values to know which callback to call.
* This happens after getting a response from the native app.
*/
const CALLBACK_SET_TOKEN = "externalAuthSetToken";
const CALLBACK_REVOKE_TOKEN = "externalAuthRevokeToken";
@@ -28,6 +33,9 @@ declare global {
revokeExternalAuth(payload: string);
externalBus(payload: string);
};
externalAppV2?: {
postMessage(payload: string): void;
};
webkit?: {
messageHandlers: {
getExternalAuth: {
@@ -44,9 +52,9 @@ declare global {
}
}
if (!window.externalApp && !window.webkit) {
if (!window.externalApp && !window.webkit && !window.externalAppV2) {
throw new Error(
"External auth requires either externalApp or webkit defined on Window object."
"External auth requires either externalApp, externalAppV2, or webkit defined on Window object."
);
}
@@ -95,7 +103,11 @@ export class ExternalAuth extends Auth {
// we sleep 1 microtask to get the promise to actually set it on the window object.
await Promise.resolve();
if (window.externalApp) {
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "getExternalAuth", payload })
);
} else if (window.externalApp) {
window.externalApp.getExternalAuth(JSON.stringify(payload));
} else {
window.webkit!.messageHandlers.getExternalAuth.postMessage(payload);
@@ -119,7 +131,11 @@ export class ExternalAuth extends Auth {
// we sleep 1 microtask to get the promise to actually set it on the window object.
await Promise.resolve();
if (window.externalApp) {
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "revokeExternalAuth", payload })
);
} else if (window.externalApp) {
window.externalApp.revokeExternalAuth(JSON.stringify(payload));
} else {
window.webkit!.messageHandlers.revokeExternalAuth.postMessage(payload);
@@ -132,6 +148,7 @@ export class ExternalAuth extends Auth {
export const createExternalAuth = async (hassUrl: string) => {
const auth = new ExternalAuth(hassUrl);
if (
window.externalAppV2 ||
window.externalApp?.externalBus ||
(window.webkit && window.webkit.messageHandlers.externalBus)
) {
+28 -4
View File
@@ -187,6 +187,11 @@ interface EMOutgoingMessageFocusElement extends EMMessage {
};
}
// These types are handled internally by the Android app via postMessage.
// They are not sent by the frontend and should not be used directly.
// They are intentionally listed here to prevent anyone from using them unintentionally.
type RejectedEMMessageType = "onHomeAssistantSetTheme" | "handleBlob";
type EMOutgoingMessageWithoutAnswer =
| EMMessageResultError
| EMMessageResultSuccess
@@ -393,8 +398,16 @@ export class ExternalMessaging {
* Send message to external app that expects a response.
* @param msg message to send
*/
public sendMessage<T extends keyof EMOutgoingMessageWithAnswer>(
msg: EMOutgoingMessageWithAnswer[T]["request"]
public sendMessage<
T extends keyof EMOutgoingMessageWithAnswer,
TType extends string = EMOutgoingMessageWithAnswer[T]["request"]["type"],
>(
msg: EMOutgoingMessageWithAnswer[T]["request"] & {
type: TType &
(TType extends RejectedEMMessageType
? "ERROR: message type is rejected"
: {});
}
): Promise<EMOutgoingMessageWithAnswer[T]["response"]> {
const msgId = ++this.msgId;
msg.id = msgId;
@@ -412,7 +425,14 @@ export class ExternalMessaging {
* Send message to external app without expecting a response.
* @param msg message to send
*/
public fireMessage(msg: EMOutgoingMessageWithoutAnswer) {
public fireMessage<T extends string>(
msg: EMOutgoingMessageWithoutAnswer & {
type: T &
(T extends RejectedEMMessageType
? "ERROR: message type is rejected"
: {});
}
) {
if (!msg.id) {
msg.id = ++this.msgId;
}
@@ -473,7 +493,11 @@ export class ExternalMessaging {
// eslint-disable-next-line no-console
console.log("Sending message to external app", msg);
}
if (window.externalApp) {
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "externalBus", payload: msg })
);
} else if (window.externalApp) {
window.externalApp.externalBus(JSON.stringify(msg));
} else {
window.webkit!.messageHandlers.externalBus.postMessage(msg);
-1
View File
@@ -303,7 +303,6 @@ export const provideHass = (
debugConnection: false,
kioskMode: false,
suspendWhenHidden: false,
moreInfoEntityId: null as any,
// @ts-ignore
async callService(domain, service, data) {
if (data && "entity_id" in data) {
+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));

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