Compare commits

...

139 Commits

Author SHA1 Message Date
Wendelin d6d5290afb review 2026-06-02 15:56:45 +02:00
Wendelin 680cc8b564 fix 2026-06-02 10:22:10 +02:00
Wendelin 87395f83b4 Update docs and clean up 2026-06-02 09:58:33 +02:00
Wendelin db3732aa31 Fix ha-domain-integration 2026-06-02 09:34:34 +02:00
Wendelin 10751ea4e9 update jsdocs 2026-06-02 09:09:59 +02:00
Wendelin e8a2ddbb45 use virtualized list in zha 2026-06-02 09:09:07 +02:00
Wendelin e072a66bf0 Simplify 2026-06-01 17:18:19 +02:00
Wendelin a1305ba8fe Fix keyboard nav and focus 2026-06-01 13:34:41 +02:00
Wendelin b73732acdb Card visibility-status use ha-alert (#52271) 2026-05-28 10:57:41 +01:00
Wendelin d950514104 Fix automation note keyboard a11y (#52270) 2026-05-28 10:56:12 +01:00
Simon Lamon f37cf1e848 Don't redispatch the original event in a checklist item (#52242) 2026-05-28 08:26:00 +03:00
Paulus Schoutsen a188ef1b7a Fix resend-verification flash and concurrency on cloud signup (#52244)
Resending the confirmation email reused the registration code path, so
the flash on the login screen said "Account created!" even though no
new account was created. Pass a message key to _verificationEmailSent
so resend can show "Verification email sent." instead.

_handleResendVerifyEmail also never set _requestInProgress, so the
resend button (and the start-trial button, which share that flag) were
not disabled while a resend was in flight and could be clicked
repeatedly. Set the flag at the start and clear it on terminal errors;
_verificationEmailSent already clears it on success.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-28 08:11:54 +03:00
Wendelin 087ef159df Fix ha-radio-option checked theming (#52237)
Update ha-radio-option theming to use checked-icon-color for text and border
2026-05-27 15:58:00 +02:00
Bram Kragten e39e1b3f5b Merge branch 'rc' into dev 2026-05-27 15:29:28 +02:00
Bram Kragten ff583d2274 Bumped version to 20260527.0 2026-05-27 15:26:39 +02:00
Wendelin d4de29e073 Rename automation trigger behavior options (#52224) 2026-05-27 15:24:53 +02:00
Wendelin 97dfed0cc4 Rename automation comments to note (#52219) 2026-05-27 15:23:27 +02:00
Bram Kragten 8b3df752da Add associated zone option for device trackers (#52211) 2026-05-27 15:18:01 +02:00
Paul Bottein 8c0d547962 Render small media browser thumbnails without blur (#52230)
* Render small media browser thumbnails without blur

* Only 16 pixels and no svg

* Skip brand url

* Media selector
2026-05-27 15:17:20 +02:00
Wendelin 5e3d84f0ad Add live test state message tooltip (#52233) 2026-05-27 15:08:43 +02:00
Petar Petrov b4e30bdf63 Fix energy compare bars stacking when compare month has more days (#52221) 2026-05-27 15:01:33 +02:00
Petar Petrov 4fcae4231c Remove redundant log-axis non-positive data preprocessing (#52222) 2026-05-27 14:59:37 +02:00
Wendelin 2aecf33955 Fix app details in tablet width (#52234) 2026-05-27 14:54:02 +02:00
Paulus Schoutsen 5f26a2b3da Show verify-email flash after cloud signup (#52232)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-27 14:46:24 +02:00
Paul Bottein b08f5bcb34 Add custom card suggestions in the entity card picker (#52228)
* Add custom card suggestions in the entity card picker

* Prettier

* rename function

* Use ensure array
2026-05-27 14:38:53 +02:00
Wendelin c329e5b827 Revert "Automation triggers - auto IDs" (#52226) 2026-05-27 14:19:38 +02:00
Paulus Schoutsen 97f591337d Fix cloud TTS try dialog failing on default browser target (#52231)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:17:18 +02:00
Bram Kragten e6e6e75f73 Revert "Add-on iframe: delegate microphone + camera Permissions Policy" (#52229) 2026-05-27 13:08:57 +02:00
Wendelin ff334de0ca Fix checked radio option (#52227) 2026-05-27 12:46:14 +02:00
Bram Kragten 8dbe97b480 Add device step to matter add flow (#52216)
* Add device step to matter add flow

* Update matter-add-device-device-added.ts
2026-05-27 12:48:14 +03:00
Bram Kragten 7bea54851d Remove advanced mode completely (#52212) 2026-05-27 09:20:48 +00:00
renovate[bot] 7171575f8c Update dependency @html-eslint/eslint-plugin to v0.61.0 (#52220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 11:20:41 +03:00
renovate[bot] f4143c2070 Update dependency echarts to v6.1.0 (#52168)
* Update dependency echarts to v6.1.0

* Fix axis-proxy patch for echarts 6.1.0 AxisProxy internals

ECharts 6.1.0 uses hostedBy() and _window.value instead of direct model
comparison and _valueWindow. Update the boundaryFilter patch and contract
tests so CI passes with the dependency bump.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 11:08:23 +03:00
Paul Bottein bbe6b88533 Add more card suggestions in the entity card picker (#52218)
Co-authored-by: Wendelin <w@pe8.at>
2026-05-27 09:32:54 +02:00
Jan-Philipp Benecke 3a0c85cd3e Migrate top app bar to plain HTML and drop mwc dependency (#52165) 2026-05-27 08:57:38 +02:00
Petar Petrov d22e2b8dd5 Add battery state of charge badges to energy panel (#52210)
Show battery SOC as entity badge on energy Now tab

Co-authored-by: MindFreeze <noreply@anthropic.com>
2026-05-26 20:15:22 +02:00
Wendelin 45e7d86bf8 Increase helper font-size (#52214) 2026-05-26 13:41:37 +00:00
Petar Petrov d1bf5fe33c Use context instead of hass for localize in low level components (#52177) 2026-05-26 15:26:09 +02:00
Aidan Timson fb0a54231a Show device name tip with link to editor, disable update button when state is clean (#52024) 2026-05-26 13:32:16 +02:00
Jan-Philipp Benecke a147fc4fee Fix padding of vertical tile card content (#52198) 2026-05-26 08:21:05 +03:00
karwosts a300085208 Fix calendar panel for non-admin (#52203) 2026-05-26 08:20:26 +03:00
renovate[bot] 44989a6972 Update dependency date-fns to v4.3.0 (#52205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 08:19:30 +03:00
Yosi Levy 54a8e6c294 RTL fix for automation row (#52200) 2026-05-25 12:38:34 +02:00
Yosi Levy bfec22d828 RTL fix for new suggestion tree (#52199) 2026-05-25 11:52:54 +02:00
steven cde6450cfc Fix stale wake word display after wake word change in voice satellite set up wizard (#52194)
Fix stale wake word display after wake word change in satellite wizard

The config re-fetch was fire-and-forgotten, so the step transition to
STEP.WAKEWORD raced ahead with stale assistConfiguration. Awaiting the
fetch ensures the fresh active_wake_words are in place before rendering.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:41:56 +00:00
Petar Petrov ab39e70629 Recover brand icons after Home Assistant restart (#52158)
* Recover brand icons after Home Assistant restart

* Make _refreshBrandsAccessToken async
2026-05-25 09:36:50 +02:00
J. Nick Koston 69f209e3c3 Teach Bluetooth UI about auto scanning mode (#52192)
* Teach Bluetooth UI about auto scanning mode

* Drop unreachable auto cases and add isScannerStateMismatch tests
2026-05-25 09:30:03 +02:00
J. Nick Koston f4c5561a54 Show raw advertisement bytes in Bluetooth device info (#52193)
* Show raw advertisement bytes in Bluetooth device info

* Use plain div for raw hex to avoid fragile pre whitespace
2026-05-25 09:24:46 +02:00
renovate[bot] 5147937a6f Update dependency generate-license-file to v4.2.1 (#52195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:24:42 +02:00
renovate[bot] ee39605aa7 Update dependency intl-messageformat to v11.2.7 (#52197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:24:38 +02:00
renovate[bot] 4af4f1dc51 Update dependency idb-keyval to v6.2.4 (#52190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 14:01:55 +00:00
renovate[bot] a2d8859d94 Update dependency @date-fns/tz to v1.5.0 (#52187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 14:03:30 +03:00
renovate[bot] afea8180c4 Update dependency idb-keyval to v6.2.3 (#52186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 13:53:50 +03:00
dependabot[bot] b9c077489d Bump github/codeql-action from 4.35.4 to 4.35.5 (#52183)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.4 to 4.35.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 09:33:38 +03:00
karwosts 440bb32056 Remove unintended sort from select selector (#52179) 2026-05-23 21:14:24 +02:00
renovate[bot] 8f371621ad Update dependency @rspack/core to v2.0.4 (#52178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 16:18:20 +03:00
renovate[bot] 61815b20e3 Update vitest monorepo to v4.1.7 (#52173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 13:03:54 +02:00
ildar170975 1942fa3a77 hui-entity-editor: fix vertical spacings (#52170)
fix spacings
2026-05-23 10:04:46 +02:00
renovate[bot] 865e67a06f Update Yarn to v4.15.0 (#52169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 10:03:23 +02:00
renovate[bot] 412dce4c1f Update dependency tinykeys to v4 (#52172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 10:02:58 +02:00
Jan-Philipp Benecke ced2ac7ad5 Fix ha-drawer z-index (#52167)
Fix ha-drawer index
2026-05-22 20:33:05 +02:00
renovate[bot] 6649f52bcd Update dependency tinykeys to v3.1.0 (#52166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 17:38:26 +00:00
Pascal Vizeli 7dbd6ae5a2 Add-on iframe: delegate microphone + camera Permissions Policy (#52068)
* Add-on iframe: delegate microphone + camera Permissions Policy

The add-on ingress iframe in ``ha-panel-app.ts`` ships without an
``allow=`` attribute, so the Permissions Policy default of *deny*
applies for ``microphone`` and ``camera`` on the cross-origin
iframe. An add-on that wants to call ``getUserMedia`` — voice
notes, dictation, video calls, photo capture — fails silently with
``NotAllowedError`` before the browser even surfaces the permission
prompt.

The failure is most visible on the Android Companion app, where
there's no "open in a new tab" escape: the user presses the mic
button and nothing happens, no toast, no logs.

Delegate ``microphone``, ``camera``, and ``clipboard-write`` to the
add-on iframe. Add-ons are first-party software the user explicitly
installs, and Chrome's runtime permission prompt still gates the
hardware access — the ``allow=`` attribute just lets the iframe
*request* the prompt instead of being blocked at the policy layer.

``clipboard-write`` is bundled in because the next-most-frequent
silent-fail in add-on land is ``navigator.clipboard.writeText`` for
"copy link" / "copy code" affordances, blocked by the same
mechanism.

* Sandbox add-on ingress iframe without allow-same-origin

Split IFRAME_SANDBOX into two constants: IFRAME_SANDBOX (without
allow-same-origin) for add-on ingress iframes that need origin
isolation, and IFRAME_SANDBOX_SAME_ORIGIN for external iframes
that need same-origin access.

This ensures add-on iframes can't inherit camera/microphone
permissions already granted to the Home Assistant origin, and
prevents same-origin iframes from removing their own sandbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 19:31:56 +02:00
Wendelin e1528d21b3 Migrate md-lists cloud dashboard and devtools (#52163)
Migrate lists in cloud and dev tools=
2026-05-22 18:24:16 +03:00
renovate[bot] 79cb3137f2 Update tsparticles to v4.0.5 (#52162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 18:17:04 +03:00
Jan-Philipp Benecke 313360701a Revamp ZHA group page UI (#52124) 2026-05-22 16:24:59 +02:00
renovate[bot] b100d9577d Update formatjs monorepo (#52159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 15:56:02 +03:00
Wendelin 44ce303302 Fix dropdown keyboard scroll (#52157) 2026-05-22 13:47:43 +01:00
Aidan Timson 8f76613068 Add more quick links to device page (#52137)
* Add more quick links to device page

* Move shared keys to common location
2026-05-22 12:45:37 +03:00
Aidan Timson 85dff6640a Use ha-list-nav for each section in device page related card (#52142)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-22 11:22:36 +02:00
Aidan Timson ab7c892b6b Add to for area page (#52141)
* Setup add to area page

* Remove 3 buttons, move to single add to button next to add a picture button

* Use normal size buttons

* Restructure layout with picture

* Remove div when both conditions are met

* Use mixin

* Fix imports
2026-05-22 12:02:38 +03:00
Wendelin 3fe57ad724 webawesome 3.7.0 (#52155) 2026-05-22 11:24:16 +03:00
pcan08 1caf1d99b5 Migrate theme-picker to ha-generic-picker (#52067)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-05-22 06:32:39 +00:00
renovate[bot] 483df2fa2f Update dependency marked to v18.0.4 (#52153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:52:04 +03:00
Stefan Agner e0adb006b6 Show time zone picker in onboarding when browser can't resolve IANA zone (#52146)
Some environments (e.g. Android WebView/emulator) return a UTC offset like
"+00:00" from Intl.DateTimeFormat().resolvedOptions().timeZone instead of an
IANA zone name. Submitting that to saveCoreConfig fails with "invalid time
zone", leaving users stuck on the country step.

Detect this by checking the resolved value against the google-timezones-json
list used by ha-timezone-picker, and surface the picker on the core-config
step when no IANA zone could be detected from the browser or the location
detect API.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:26:50 +03:00
renovate[bot] 50e34015b3 Update tsparticles to v4.0.4 (#52152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:23:24 +03:00
renovate[bot] c1c926c631 Update tsparticles to v4.0.3 (#52148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:11:18 +03:00
renovate[bot] c41afac57c Update dependency typescript-eslint to v8.59.4 (#52147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:10:58 +03:00
Aidan Timson 8856c26929 Add quick links to area page, add area query param support (#52133)
* Add quick links to area page

* Typing

* Add query param support for areas

* hint for the rest

* Add support for helpers

* Add counts
2026-05-21 18:59:20 +02:00
Wendelin 4a0fe3190c Backups: Migrate md-list to new list-base (#52136)
Migrate md-list to new list-base
2026-05-21 18:57:24 +02:00
karwosts 08f7e97462 Use display precision in statistic card (#52138) 2026-05-21 18:53:04 +02:00
Joakim Plate a5791c8c08 Switch to power-standby for media player (#52127) 2026-05-21 18:53:01 +02:00
Wendelin 6a98a74c58 Fix trigger time margin bottom (#52144)
Fix trigger time margin
2026-05-21 18:39:35 +02:00
renovate[bot] c1df3bc38e Update Node.js to v24.16.0 (#52140) 2026-05-21 17:03:13 +02:00
Wendelin 58d4edaa63 Respect via device in device picker, device list (#52131)
* Respect via device in device picker

* Add context to device data table too
2026-05-21 16:01:43 +02:00
Wendelin 176841e647 Automation triggers - auto IDs (#52129)
* Add auto trigger ids

* improve

* Fix paste with no trigger id

* Fix trigger-id-chip and add jsdocs

* review
2026-05-21 16:52:03 +03:00
renovate[bot] 0759e82b47 Update dependency date-fns to v4.2.1 (#52135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 16:44:50 +03:00
Bram Kragten 9298e00f20 Merge branch 'rc' 2026-05-14 11:32:44 +02:00
Bram Kragten 70085d4bad Bumped version to 20260429.4 2026-05-14 11:32:29 +02:00
Wendelin d83a553b62 Reactivate iOS focus element (#52020) 2026-05-14 11:31:23 +02:00
Wendelin cab5c6af30 Add macOS version mapping for Safari 26 support (#51999) 2026-05-14 11:24:44 +02:00
Petar Petrov d44d8a6dbd Fix water sankey untracked consumption with nested sub-trackers (#51998) 2026-05-14 11:24:43 +02:00
karwosts 3cf1d94b92 Fix sensor card when visibility changes (#51953)
* Fix sensor card when visibility changes

* History card

* map card

* trend graph

* minor change
2026-05-14 11:23:31 +02:00
Tom Carpenter 9f5f849e32 Fix demo instance mock recorder data generation (#51950)
Fix demo mock recorder data end times

The mock recorder was setting the start and end time for each of the samples to be the same value, causing the solar graph in the energy dashboard to render incorrectly.

Fix the recorder to set the end time of each sample to the start time of the next.
2026-05-14 11:21:23 +02:00
karwosts 27e9926363 Fix heading badge current-entity visibility (#51942) 2026-05-14 11:21:22 +02:00
karwosts efe734892a Fix create new person with login (#51939) 2026-05-14 11:21:21 +02:00
Tom Carpenter b3d79e312d Remove extra padding to right of ha-switch (#51932)
Fix empty padding to right of ha-switch

When the label slot for the ha-switch is empty, the initial margin is still present which causes an odd misalignment on the switches in e,g, the entities card.

To fix this, if the label slot is empty, hide the label to remove the unwanted margin.
2026-05-14 11:21:19 +02:00
Marcin Bauer ecfef9e112 Improve continue on error tooltip in automation editor (#51926)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-14 11:21:18 +02:00
George Caliment ca960446f0 Fixed blueprint rows event result chip render when collapsed (#51910) 2026-05-14 11:21:17 +02:00
Petar Petrov a6eb722025 Clamp power sources graph usage line to non-negative (#51902) 2026-05-14 11:21:16 +02:00
Paul Bottein f3ff01ace4 Fix race condition loading home dashboard favorites (#51901) 2026-05-14 11:21:15 +02:00
karwosts d5e1a373ec Fix entity filter card (#51895) 2026-05-14 11:21:14 +02:00
Wendelin e1b9a1a185 Fix content padding picker (#51889) 2026-05-14 11:21:12 +02:00
Paul Bottein efe8eaa941 Move logs page search bar out of the toolbar (#51887) 2026-05-14 11:21:11 +02:00
Wendelin 5856196ef3 Improve automation event chips action, condition (#51886) 2026-05-14 11:21:10 +02:00
Clément Notin 2671a8c64b Fix quick bar search not focused on first open (#51822)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-14 11:21:09 +02:00
Bram Kragten 8620653a54 Merge branch 'rc' 2026-05-06 11:19:42 +02:00
Bram Kragten c4f4cbd323 Bumped version to 20260429.3 2026-05-06 11:18:01 +02:00
Paul Bottein 2e0df00f0f Fix name for battery entities without device (#51879) 2026-05-06 11:17:09 +02:00
Wendelin ce02f8072d Reduce progress bar default height (#51878)
reduce progress bar default height to 12px
2026-05-06 11:17:08 +02:00
Paul Bottein c973aa7516 Fix media controls in media player more info dialog (#51877) 2026-05-06 11:17:07 +02:00
Paul Bottein 1e2328707c Fix switch clipping in view visibility editor (#51876) 2026-05-06 11:17:06 +02:00
Wendelin 56368b88cd Remove duplicate definition in semantic colors (#51875)
* Remove duplicate definition in semantic colors

* rearrange surface tokens
2026-05-06 11:17:05 +02:00
Aidan Timson fcd4f177c1 Fix Safari 14 legacy bundle require errors (#51868)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 11:17:04 +02:00
Wendelin 7423ae7316 Fix integration search shrink on mobile (#51867) 2026-05-06 11:17:03 +02:00
Marcin Bauer 4427c581f1 Fix automation row right padding and soften chip highlight animation (#51865)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 11:17:02 +02:00
Paul Bottein cf86bb9821 Use ha-switch instead of ha-control-switch in entity toggle (#51852) 2026-05-06 11:17:01 +02:00
karwosts 897802dc16 Change display for uptime sensors (#51830) 2026-05-06 11:17:00 +02:00
Paul Bottein 95edd6c2c2 20260429.2 (#51856) 2026-05-04 17:05:24 +02:00
Paul Bottein dd65173c5a Bumped version to 20260429.2 2026-05-04 17:04:06 +02:00
Paul Bottein cf26753f7d Remove daily and hourly forecast card features (#51854) 2026-05-04 17:03:54 +02:00
Paul Bottein d6ab8ffb16 Resolve service name and icon for shortcut card and badge (#51850) 2026-05-04 17:03:53 +02:00
Wendelin 2dc4b16eac Fix automation row target width (#51848) 2026-05-04 17:03:52 +02:00
Paul Bottein 1eba765bc2 Group areas floor vacuum clean (#51847) 2026-05-04 17:03:51 +02:00
Wendelin 398479ddd7 Use ha-switch in ha-automation-picker (#51846)
use ha-switch in ha-automation-picker
2026-05-04 17:03:50 +02:00
Paul Bottein c4fd7bb3e1 Fix entity toggle switch size (#51845) 2026-05-04 17:03:49 +02:00
Isaac (Kwangjin Ko) 4cfc67a95e ha-humidifier-state: fix incorrect translation key for 'Currently' (#51843) 2026-05-04 17:03:48 +02:00
Brooke Hatton e38d1964ca Remove battery chargers from maintenance dashboard (#51835) 2026-05-04 17:03:47 +02:00
Paul Bottein ec8b5c77bd Add min touch size for control switch (#51826) 2026-05-04 17:03:46 +02:00
Simon Lamon 425f2775e2 Missing toggle in switch group (#51825)
Missing toggle
2026-05-04 17:03:45 +02:00
Brooke Hatton 3a3d8191a3 Adjust Copy for maintenance summary card and include unavailable device count (#51815)
* Adjust Copy For summary card

* Further tweak copy and include unavailable devices
2026-05-04 17:03:44 +02:00
Aidan Timson 04fca68549 Add gap between hui editors and previews on mobile (#51811) 2026-05-04 17:03:43 +02:00
Paul Bottein 3046f3e47d 20260429.1 (#51817) 2026-04-30 20:33:33 +02:00
Paul Bottein 35601a0900 Bumped version to 20260429.1 2026-04-30 20:32:28 +02:00
Wendelin e7016c15af Fix ha-select undefined value (#51800)
Fix ha-select undefined

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 20:32:08 +02:00
Wendelin 624521e30b Hide tooltip on mobile clients in ha-sidebar component (#51799) 2026-04-30 20:32:08 +02:00
Bram Kragten 4876bfa639 Add tooltips for Jinja editors (#51792)
* Add descriptions to Jinja2 tags, filters, expressions, tests and variables

All standard Jinja2 tags, filters, and expression completions now carry
info and detail strings so the autocomplete info popover shows meaningful
documentation when users browse them — not just HA-specific functions.

* Add keyboard shortcut tip to the template developer tool

A ha-tip below the editor card now shows users that Ctrl+Space triggers
autocomplete, Ctrl+F opens the search panel, and F11 toggles fullscreen,
making the editor's built-in features more discoverable.

* Add hover tooltips for Jinja2 functions, filters and expressions

Hovering over a function, filter, tag, test, or variable name inside a
Jinja2 template shows a tooltip with its signature and description.
Non-tag completions also get a help-circle icon linking to the
corresponding Home Assistant template-functions documentation page.

The tooltip is rendered as a custom Lit element (ha-code-editor-jinja-hover)
that takes the Completion object and an optional docUrl as properties.

The tooltip source (haJinjaHoverSource) is wired into ha-code-editor
via CodeMirror's hoverTooltip extension. The documentationUrl() helper
is used so the link points to the correct subdomain (www / rc / next)
based on the running HA version.

* Add hover tooltips for Jinja2 hover + arg value tooltips for entity/device/area

Wire haJinjaHoverSource into ha-code-editor via CodeMirror hoverTooltip.
Two types of hover are now shown in jinja2/yaml mode:

- Hovering a function/filter/tag/expression name shows its signature,
  description, and a doc-link icon (non-tags only).
- Hovering a string-literal argument of a known HA Jinja function (e.g.
  states(), device_name(), area_entities()) shows the friendly name,
  current state, device, and area for entity_id arguments; the device
  name and area for device_id arguments; and the area name for area_id
  arguments. The same applies to states["entity_id"] subscripts.

The arg-value tooltip reuses CompletionItem / ha-code-editor-completion-items
(the same component used for autocomplete info popovers) via a new
ha-code-editor-jinja-arg-hover element. HA registry data is passed from
ha-code-editor via a HassArgHoverContext interface to keep jinja_ha_completions.ts
free of HomeAssistant type imports.

* only add tip for autocomplete

* review
2026-04-30 20:32:07 +02:00
AlCalzone 5dea0764b2 Expose Z-Wave exclusion instructions when removing device (#51788)
* Expose Z-Wave exclusion instructions when removing device

* text tweaks

* Apply suggestion from @MindFreeze

* Apply suggestions from code review

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

* bring back comment

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-30 20:32:05 +02:00
Paul Bottein 121ed7ac1f 20260429.0 (#51790) 2026-04-29 16:47:32 +02:00
243 changed files with 7270 additions and 4269 deletions
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.16.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.14.1.cjs
yarnPath: .yarn/releases/yarn-4.15.0.cjs
-1
View File
@@ -1,4 +1,3 @@
import "@material/mwc-top-app-bar-fixed";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../src/components/ha-icon-button";
+10 -5
View File
@@ -1,4 +1,3 @@
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
@@ -11,6 +10,7 @@ import type { HaDrawer } from "../../src/components/ha-drawer";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import { PAGES, SIDEBAR } from "../build/import-pages";
@@ -84,7 +84,7 @@ class HaGallery extends LitElement {
<div class="drawer-title">Home Assistant Design</div>
<div class="sidebar">${sidebar}</div>
<div slot="appContent" class="app-content">
<mwc-top-app-bar-fixed>
<ha-top-app-bar-fixed>
<ha-icon-button
slot="navigationIcon"
@click=${this._menuTapped}
@@ -94,7 +94,7 @@ class HaGallery extends LitElement {
<div slot="title">
${PAGES[this._page].metadata.title || this._page.split("/")[1]}
</div>
</mwc-top-app-bar-fixed>
</ha-top-app-bar-fixed>
<div class="content">
${PAGES[this._page].description
? html`
@@ -227,11 +227,12 @@ class HaGallery extends LitElement {
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 256px;
--header-height: 64px;
}
.sidebar {
box-sizing: border-box;
max-height: calc(100vh - 64px);
max-height: calc(100vh - var(--header-height));
overflow-y: auto;
padding: 4px;
}
@@ -243,7 +244,7 @@ class HaGallery extends LitElement {
display: flex;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
min-height: 64px;
min-height: var(--header-height);
padding: 0 16px;
}
@@ -277,6 +278,10 @@ class HaGallery extends LitElement {
background: var(--primary-background-color);
}
ha-drawer[type="dismissible"][open] ha-top-app-bar-fixed {
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
}
.content {
flex: 1;
}
@@ -62,10 +62,11 @@ host reflects `aria-multiselectable`.
**Events**
- `ha-list-selected`selection changed. Detail
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
`index` is a `number` in single mode (`-1` when nothing selected) and a
`Set<number>` in multi mode.
- `ha-list-item-selected`an option was selected. Detail is the option's
index (`number`). In single mode this is the only selection event; in multi
mode it fires for each option added to the selection.
- `ha-list-item-deselected` — an option was deselected (multi mode only). Detail
is the option's index (`number`).
**Methods / getters**
+53 -13
View File
@@ -20,7 +20,6 @@ import "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-base";
import "../../../../src/components/list/ha-list-nav";
import "../../../../src/components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
type Appearance = "line" | "checkbox";
type Position = "start" | "end";
@@ -185,7 +184,7 @@ export class DemoHaList extends LitElement {
<ha-card header="Single select, appearance=line">
<ha-list-selectable
aria-label="Single select"
@ha-list-selected=${this._onSingle}
@ha-list-item-selected=${this._onSingle}
>
${this._options.map(
(o, i) => html`
@@ -205,7 +204,8 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi select line"
@ha-list-selected=${this._onMultiLine}
@ha-list-item-selected=${this._onMultiLineSelected}
@ha-list-item-deselected=${this._onMultiLineDeselected}
>
${this._options.map(
(o, i) => html`
@@ -227,7 +227,8 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi checkbox start"
@ha-list-selected=${this._onMultiCheckStart}
@ha-list-item-selected=${this._onMultiCheckStartSelected}
@ha-list-item-deselected=${this._onMultiCheckStartDeselected}
>
${this._options.map(
(o, i) => html`
@@ -253,7 +254,8 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
<ha-list-selectable
multi
aria-label="Multi checkbox end"
@ha-list-selected=${this._onMultiCheckEnd}
@ha-list-item-selected=${this._onMultiCheckEndSelected}
@ha-list-item-deselected=${this._onMultiCheckEndDeselected}
>
${this._options.map(
(o, i) => html`
@@ -347,20 +349,58 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
this._buttonClicks++;
};
private _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
this._single = ev.detail.index;
private _withIndex(
value: number | Set<number>,
index: number,
selected: boolean
): Set<number> {
const next = new Set(value instanceof Set ? value : []);
if (selected) {
next.add(index);
} else {
next.delete(index);
}
return next;
}
private _onSingle = (ev: CustomEvent<number>) => {
this._single = ev.detail;
};
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiLine = ev.detail.index;
private _onMultiLineSelected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, true);
};
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckStart = ev.detail.index;
private _onMultiLineDeselected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, false);
};
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckEnd = ev.detail.index;
private _onMultiCheckStartSelected = (ev: CustomEvent<number>) => {
this._multiCheckStart = this._withIndex(
this._multiCheckStart,
ev.detail,
true
);
};
private _onMultiCheckStartDeselected = (ev: CustomEvent<number>) => {
this._multiCheckStart = this._withIndex(
this._multiCheckStart,
ev.detail,
false
);
};
private _onMultiCheckEndSelected = (ev: CustomEvent<number>) => {
this._multiCheckEnd = this._withIndex(this._multiCheckEnd, ev.detail, true);
};
private _onMultiCheckEndDeselected = (ev: CustomEvent<number>) => {
this._multiCheckEnd = this._withIndex(
this._multiCheckEnd,
ev.detail,
false
);
};
static styles = css`
+21 -10
View File
@@ -1,10 +1,12 @@
import type { TemplateResult, PropertyValues } from "lit";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../../../src/components/ha-tip";
import "../../../../src/components/ha-card";
import { provide } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-tip";
import { internationalizationContext } from "../../../../src/data/context";
import type { HomeAssistantInternationalization } from "../../../../src/types";
const tips: (string | TemplateResult)[] = [
"Test tip",
@@ -14,16 +16,25 @@ const tips: (string | TemplateResult)[] = [
@customElement("demo-components-ha-tip")
export class DemoHaTip extends LitElement {
@provide({ context: internationalizationContext })
@state()
protected _i18n: HomeAssistantInternationalization = {
localize: ((key: string) => 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,
};
protected render(): TemplateResult {
return html` ${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-tip ${mode} demo">
<div class="card-content">
${tips.map(
(tip) =>
html`<ha-tip .hass=${provideHass(this)}>${tip}</ha-tip>`
)}
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
</div>
</ha-card>
</div>
+27 -30
View File
@@ -38,24 +38,24 @@
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.4.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.5",
"@formatjs/intl-displaynames": "7.3.7",
"@formatjs/intl-durationformat": "0.10.11",
"@formatjs/intl-getcanonicallocales": "3.2.8",
"@formatjs/intl-listformat": "8.3.7",
"@formatjs/intl-locale": "5.3.7",
"@formatjs/intl-numberformat": "9.3.8",
"@formatjs/intl-pluralrules": "6.3.7",
"@formatjs/intl-relativetimeformat": "12.3.7",
"@formatjs/intl-datetimeformat": "7.4.6",
"@formatjs/intl-displaynames": "7.3.8",
"@formatjs/intl-durationformat": "0.10.12",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.8",
"@formatjs/intl-locale": "5.3.8",
"@formatjs/intl-numberformat": "9.3.9",
"@formatjs/intl-pluralrules": "6.3.8",
"@formatjs/intl-relativetimeformat": "12.3.8",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.3.1-ha.3",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -65,17 +65,14 @@
"@material/mwc-base": "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-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@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",
"@material/web": "2.4.1",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.0.2",
"@tsparticles/preset-links": "4.0.2",
"@tsparticles/engine": "4.0.5",
"@tsparticles/preset-links": "4.0.5",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -86,19 +83,19 @@
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.2.0",
"date-fns": "4.3.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.0.0",
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.3.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.6",
"idb-keyval": "6.2.4",
"intl-messageformat": "11.2.7",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -106,7 +103,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.3",
"marked": "18.0.4",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -118,7 +115,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"tinykeys": "4.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -135,13 +132,13 @@
"@babel/preset-env": "7.29.5",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.60.0",
"@html-eslint/eslint-plugin": "0.61.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.3",
"@rspack/core": "2.0.4",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -160,7 +157,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.6",
"@vitest/coverage-v8": "4.1.7",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
@@ -175,7 +172,7 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"generate-license-file": "4.1.1",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -201,9 +198,9 @@
"terser-webpack-plugin": "5.6.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.3",
"typescript-eslint": "8.59.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.6",
"vitest": "4.1.7",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -219,8 +216,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.14.1",
"packageManager": "yarn@4.15.0",
"volta": {
"node": "24.15.0"
"node": "24.16.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260429.0"
version = "20260527.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+15 -1
View File
@@ -1,6 +1,20 @@
import timezones from "google-timezones-json";
import { TimeZone } from "../../data/translation";
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
// instead of an IANA zone name. Only accept values that are known IANA zones,
// matching the list used by ha-timezone-picker.
const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
? RESOLVED_RAW
: undefined;
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
// Browser time zone can be determined from Intl, with fallback to UTC for polyfill or no support.
export const LOCAL_TIME_ZONE = RESOLVED_TIME_ZONE ?? "UTC";
+22 -2
View File
@@ -1,8 +1,16 @@
import { consume } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import { entitiesContext, statesContext } from "../../data/context";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../../types";
import {
entitiesContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { LocalizeFunc } from "../translations/localize";
import { transform } from "./transform";
interface ConsumeEntryConfig {
@@ -91,3 +99,15 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
return typeof id === "string" ? entities?.[id] : undefined;
}
);
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed — the decorated property updates
* whenever the i18n context changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
internationalizationContext,
undefined,
({ localize }) => localize
);
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -12,6 +13,7 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@@ -19,6 +21,8 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
@@ -27,6 +31,9 @@ export class HaAutomationRowLiveTest extends LitElement {
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
@@ -128,7 +128,9 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding: 0 0 0 var(--ha-space-3);
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
min-height: 48px;
align-items: flex-start;
cursor: pointer;
@@ -144,6 +146,8 @@ export class HaAutomationRow extends LitElement {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: calc(var(--ha-space-2) * -1);
margin-inline-start: calc(var(--ha-space-2) * -1);
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
-18
View File
@@ -956,29 +956,11 @@ export class HaChartBase extends LitElement {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
| undefined;
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
const series = ensureArray(this.data).map((s) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
if (data && s.type === "line") {
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return {
...s,
data: (data as LineSeriesOption["data"])!.map((v) =>
Array.isArray(v)
? [
v[0],
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
...v.slice(2),
]
: v
),
};
}
if (s.sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
@@ -1,13 +1,14 @@
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-dialog-footer";
import "../ha-icon-button";
@@ -24,7 +25,9 @@ import type { DataTableSettingsDialogParams } from "./show-dialog-data-table-set
@customElement("dialog-data-table-settings")
export class DialogDataTableSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _params?: DataTableSettingsDialogParams;
@@ -117,7 +120,7 @@ export class DialogDataTableSettings extends LitElement {
return nothing;
}
const localize = this._params.localizeFunc || this.hass.localize;
const localize = this._params.localizeFunc || this._localize;
const columns = this._sortedColumns(
this._params.columns,
@@ -172,7 +175,7 @@ export class DialogDataTableSettings extends LitElement {
.hidden=${!isVisible}
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="meta"
.label=${this.hass!.localize(
.label=${localize(
`ui.components.data-table.settings.${isVisible ? "hide" : "show"}`,
{ title: typeof col.title === "string" ? col.title : "" }
)}
+6 -3
View File
@@ -6,8 +6,9 @@ import {
mdiInformationOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
@@ -39,7 +40,9 @@ class HaAlert extends LitElement {
@property({ type: Boolean }) public dismissable = false;
@property({ attribute: false }) public localize?: LocalizeFunc;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
@property({ type: Boolean }) public narrow = false;
@@ -68,7 +71,7 @@ class HaAlert extends LitElement {
${this.dismissable
? html`<ha-icon-button
@click=${this._dismissClicked}
.label=${this.localize!("ui.common.dismiss_alert")}
.label=${this._localize?.("ui.common.dismiss_alert")}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
@@ -61,7 +61,6 @@ export class HaAreasDisplayEditor extends LitElement {
>
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon>
<ha-items-display-editor
.hass=${this.hass}
.items=${items}
.value=${value}
@value-changed=${this._areaDisplayChanged}
@@ -107,7 +107,6 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
></ha-svg-icon>
`}
<ha-items-display-editor
.hass=${this.hass}
.items=${groupedAreasItems[floor.floor_id]}
.value=${value}
.floorId=${floor.floor_id}
+1
View File
@@ -20,6 +20,7 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
+1
View File
@@ -294,6 +294,7 @@ export class HaDrawer extends LitElement {
border-inline-end: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
box-sizing: border-box;
transition: width var(--ha-animation-duration-normal) ease;
z-index: 6;
}
.app-content {
@@ -54,7 +54,6 @@ export class HaEntitiesDisplayEditor extends LitElement {
return html`
<ha-items-display-editor
.hass=${this.hass}
.items=${items}
.value=${value}
@value-changed=${this._itemDisplayChanged}
+1
View File
@@ -107,6 +107,7 @@ export class HaExpansionPanel extends LitElement {
}
const newExpanded = !this.expanded;
fireEvent(this, "expanded-will-change", { expanded: newExpanded });
this._container.style.overflow = "hidden";
if (newExpanded) {
+136 -132
View File
@@ -1,5 +1,5 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -9,14 +9,18 @@ import { stringCompare } from "../common/string/compare";
import { deepEqual } from "../common/util/deep-equal";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import "./list/ha-list-selectable-virtualized";
import type { HaListSelectableVirtualized } from "./list/ha-list-selectable-virtualized";
import type { HaListVirtualizedItem } from "./list/ha-list-virtualized";
interface HaFilterDevicesItem extends HaListVirtualizedItem {
name: string;
}
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@@ -34,15 +38,12 @@ export class HaFilterDevices extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
@@ -51,6 +52,20 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded || !this._listElement) {
return;
}
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 38px - height of the search input
}, 300);
}
}
protected render() {
return html`
<ha-expansion-panel
@@ -66,6 +81,7 @@ export class HaFilterDevices extends LitElement {
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@keydown=${this._handleClearFilterKeydown}
></ha-icon-button>`
: nothing}
</div>
@@ -74,75 +90,45 @@ export class HaFilterDevices extends LitElement {
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._devices(
this.hass.devices,
this._filter || "",
this.value
)}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
</lit-virtualizer>
</ha-list>`
<ha-list-selectable-virtualized
multi
.rows=${this._devices(this.hass.devices, this._filter || "")}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
: nothing}
</ha-expansion-panel>
`;
}
private _keyFunction = (device) => device?.id;
private _renderItem = (device) =>
!device
private _renderItem = (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-check-list-item
tabindex="0"
.value=${device.id}
.selected=${this.value?.includes(device.id) ?? false}
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
${computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)}
</ha-check-list-item>`;
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
...(this.value ?? []),
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
];
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
private _handleRemoved(ev: CustomEvent<number>) {
const id = this._devices(this.hass.devices, this._filter || "")[ev.detail]
.id;
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
private _expandedWillChange(ev) {
@@ -155,30 +141,38 @@ export class HaFilterDevices extends LitElement {
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
this._filter = target.value ?? "";
}
private _handleSearchKeydown(ev: KeyboardEvent) {
if (ev.key === "ArrowDown" && this._listElement) {
ev.preventDefault();
this._listElement.focus();
}
}
private _devices = memoizeOne(
(devices: HomeAssistant["devices"], filter: string, _value) => {
(
devices: HomeAssistant["devices"],
filter: string
): HaFilterDevicesItem[] => {
const values = Object.values(devices);
return values
.map((device) => ({
id: device.id,
interactive: true,
name: computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
),
}))
.filter(
(device) =>
!filter ||
computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)
.toLowerCase()
.includes(filter)
({ name }) =>
!filter || name.toLowerCase().includes(filter.toLowerCase())
)
.sort((a, b) =>
stringCompare(
computeDeviceNameDisplay(a, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(b, this.hass.localize, this.hass.states),
this.hass.locale.language
)
stringCompare(a.name, b.name, this.hass.locale.language)
);
}
);
@@ -217,6 +211,13 @@ export class HaFilterDevices extends LitElement {
});
}
private _handleClearFilterKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._clearFilter(ev);
}
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
@@ -224,58 +225,61 @@ export class HaFilterDevices extends LitElement {
value: undefined,
items: undefined,
});
this._listElement?.clearSelection();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
width: 100%;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
:host([expanded]) ha-expansion-panel {
flex: 1;
min-height: 0;
}
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`;
}
declare global {
+48 -38
View File
@@ -23,7 +23,6 @@ import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import type { HaListSelectedDetail } from "./list/types";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@@ -42,7 +41,7 @@ export class HaFilterFloorAreas extends LitElement {
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HTMLElement;
@query("ha-list-selectable") private _list?: HaListSelectable;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -75,6 +74,7 @@ export class HaFilterFloorAreas extends LitElement {
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@keydown=${this._handleClearFilterKeydown}
></ha-icon-button>`
: nothing}
</div>
@@ -83,7 +83,8 @@ export class HaFilterFloorAreas extends LitElement {
<ha-list-selectable
class="ha-scrollbar"
multi
@ha-list-selected=${this._handleListChanged}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
@@ -163,46 +164,47 @@ export class HaFilterFloorAreas extends LitElement {
`;
}
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
private _handleAdded(ev: CustomEvent<number>) {
if (!this.value) {
this.value = {};
}
const addedItem = (ev.currentTarget as HaListSelectable).items[
ev.detail
] as HaListItemOption & { type: string; value: string };
if (!addedItem) {
return;
}
if (ev.detail.diff?.added.size) {
const addedIndex = ev.detail.diff.added.values().next().value;
if (addedIndex === undefined) {
return;
}
const addedItem = (ev.currentTarget as HaListSelectable).items[
addedIndex
] as HaListItemOption & { type: string; value: string };
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
}
if (!this.value) {
this.value = {};
}
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
} else {
const removedIndex = ev.detail.diff?.removed.values().next().value;
if (removedIndex === undefined) {
return;
}
const removedItem = (ev.currentTarget as HaListSelectable).items[
removedIndex
] as HaListItemOption & { type: string; value: string };
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
(val) => val !== removedItem.value
),
};
private _handleRemoved(ev: CustomEvent<number>) {
if (!this.value) {
return;
}
const removedItem = (ev.currentTarget as HaListSelectable).items[
ev.detail
] as HaListItemOption & { type: string; value: string };
if (!removedItem) {
return;
}
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
(val) => val !== removedItem.value
),
};
}
protected updated(changed: PropertyValues<this>) {
@@ -286,6 +288,13 @@ export class HaFilterFloorAreas extends LitElement {
});
}
private _handleClearFilterKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._clearFilter(ev);
}
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
@@ -293,6 +302,7 @@ export class HaFilterFloorAreas extends LitElement {
value: undefined,
items: undefined,
});
this._list?.clearSelection();
}
static get styles(): CSSResultGroup {
+3
View File
@@ -109,6 +109,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -271,6 +273,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
></ha-picker-combo-box>
`;
}
+59 -41
View File
@@ -1,59 +1,77 @@
// @ts-ignore
import topAppBarStyles from "@material/top-app-bar/dist/mdc.top-app-bar.min.css";
import { css, html, LitElement, unsafeCSS } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-header-bar")
export class HaHeaderBar extends LitElement {
protected render() {
return html`<header class="mdc-top-app-bar">
<div class="mdc-top-app-bar__row">
<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-start"
id="navigation"
>
return html`<header class="header-bar">
<div class="row">
<section class="section" id="navigation">
<slot name="navigationIcon"></slot>
<span class="mdc-top-app-bar__title">
<span class="title">
<slot name="title"></slot>
</span>
</section>
<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-end"
id="actions"
role="toolbar"
>
<section class="section end" id="actions" role="toolbar">
<slot name="actionItems"></slot>
</section>
</div>
</header>`;
}
static get styles() {
return [
unsafeCSS(topAppBarStyles),
css`
.mdc-top-app-bar__row {
height: var(--header-height);
}
.mdc-top-app-bar {
position: static;
color: var(--mdc-theme-on-primary, #fff);
padding: var(--header-bar-padding);
}
.mdc-top-app-bar__section.mdc-top-app-bar__section--align-start {
flex: 1;
}
.mdc-top-app-bar__section.mdc-top-app-bar__section--align-end {
flex: none;
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: 24px;
padding-inline-end: initial;
}
`,
];
}
static override styles = css`
:host {
display: block;
}
.header-bar {
box-sizing: border-box;
color: var(--app-header-text-color, var(--primary-text-color));
background-color: var(
--app-header-background-color,
var(--primary-background-color)
);
padding: var(--header-bar-padding);
}
.row {
display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
height: var(--header-height);
}
.section {
display: flex;
align-items: center;
box-sizing: border-box;
min-width: 0;
height: 100%;
padding: 0 var(--ha-space-3);
}
#navigation {
flex: 1 1 auto;
}
.section.end {
flex: none;
justify-content: flex-end;
}
.title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--header-height);
padding-inline-start: var(--ha-space-6);
}
`;
}
declare global {
+7 -4
View File
@@ -2,10 +2,11 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiDotsVertical } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-icon-button";
@@ -26,7 +27,9 @@ export interface IconOverflowMenuItem {
@customElement("ha-icon-overflow-menu")
export class HaIconOverflowMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ type: Array }) public items: IconOverflowMenuItem[] = [];
@@ -44,7 +47,7 @@ export class HaIconOverflowMenu extends LitElement {
@click=${stopPropagation}
>
<ha-icon-button
.label=${this.hass.localize("ui.common.overflow_menu")}
.label=${this._localize("ui.common.overflow_menu")}
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
+1 -1
View File
@@ -14,7 +14,7 @@ class InputHelperText extends LitElement {
:host {
display: block;
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
font-size: 0.75rem;
font-size: var(--ha-font-size-s);
padding-left: 16px;
padding-right: 16px;
padding-inline-start: 16px;
+6 -3
View File
@@ -8,10 +8,11 @@ import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { orderCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types";
import type { LocalizeFunc } from "../common/translations/localize";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-icon-next";
@@ -46,7 +47,9 @@ declare global {
@customElement("ha-items-display-editor")
export class HaItemDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public items: DisplayItem[] = [];
@@ -161,7 +164,7 @@ export class HaItemDisplayEditor extends LitElement {
? html`<ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
.label=${this.hass.localize(
.label=${this._localize(
`ui.components.items-display-editor.${isVisible ? "hide" : "show"}`,
{
label: label,
+3 -1
View File
@@ -167,6 +167,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public clearable = false;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@@ -342,7 +344,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _getItems = () => {
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
if (!this.sections?.length) {
if (!this.sections?.length && !this.noSort) {
items = items.sort((entityA, entityB) => {
const sortLabelA =
typeof entityA === "string" ? entityA : entityA.sorting_label;
+15 -8
View File
@@ -1,11 +1,12 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { internationalizationContext, uiContext } from "../data/context";
import "./radio/ha-radio-group";
import type { HaRadioGroup } from "./radio/ha-radio-group";
import "./radio/ha-radio-option";
@@ -26,8 +27,6 @@ export interface SelectBoxOption {
@customElement("ha-select-box")
export class HaSelectBox extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public options: SelectBoxOption[] = [];
@property({ attribute: false }) public value?: string;
@@ -40,6 +39,14 @@ export class HaSelectBox extends LitElement {
@property({ type: Boolean, attribute: "stacked_image" })
public stackedImage = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
protected _ui?: ContextType<typeof uiContext>;
render() {
const maxColumns = this.maxColumns ?? 3;
const columns = Math.min(maxColumns, this.options.length);
@@ -62,11 +69,11 @@ export class HaSelectBox extends LitElement {
const disabled = option.disabled || this.disabled || false;
const selected = option.value === this.value;
const isDark = this.hass?.themes.darkMode || false;
const isRTL = this.hass
const isDark = this._ui?.themes.darkMode || false;
const isRTL = this._i18n
? computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
)
: false;
@@ -1,30 +1,31 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeKeys } from "../../common/translations/localize";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import type {
AutomationBehavior,
AutomationBehaviorConditionMode,
AutomationBehaviorSelector,
AutomationBehaviorTriggerMode,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-input-helper-text";
import type { SelectBoxOption } from "../ha-select-box";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"any",
"each",
"first",
"last",
"all",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public selector!: AutomationBehaviorSelector;
@@ -39,6 +40,9 @@ export class HaSelectorAutomationBehavior extends LitElement {
@property({ type: Boolean }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
protected render() {
const { mode } = this.selector.automation_behavior ?? {};
const modeKey = mode ?? "trigger";
@@ -60,7 +64,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
return html`
<ha-select-box
.hass=${this.hass}
.options=${options}
.value=${this.value ?? ""}
max_columns="1"
@@ -95,8 +98,10 @@ export class HaSelectorAutomationBehavior extends LitElement {
return translated;
}
}
return this.hass.localize(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
return (
this._localize?.(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
) || behavior
);
}
+10 -46
View File
@@ -1,11 +1,10 @@
import { mdiPlayBox, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
import { supportsFeature } from "../../common/entity/supports-feature";
import { getSignedPath } from "../../data/auth";
import type { MediaPickedEvent } from "../../data/media-player";
import {
MediaClassBrowserSettings,
@@ -13,14 +12,10 @@ import {
} from "../../data/media-player";
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import "../ha-alert";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
import "../media-player/ha-media-browser-thumbnail";
import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog";
import { ensureArray } from "../../common/array/ensure-array";
import "../ha-picture-upload";
@@ -54,8 +49,6 @@ export class HaMediaSelector extends LitElement {
filter_entity?: string | string[];
};
@state() private _thumbnailUrl?: string | null;
private _contextEntities: string[] | undefined;
private get _hasAccept(): boolean {
@@ -68,35 +61,6 @@ export class HaMediaSelector extends LitElement {
this._contextEntities = ensureArray(this.context?.filter_entity);
}
}
if (changedProps.has("value")) {
const thumbnail = this.value?.metadata?.thumbnail;
const oldThumbnail = (changedProps.get("value") as this["value"])
?.metadata?.thumbnail;
if (thumbnail === oldThumbnail) {
return;
}
if (thumbnail && isBrandUrl(thumbnail)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
this._thumbnailUrl = brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnail),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
} else if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else {
this._thumbnailUrl = thumbnail;
}
}
}
protected render() {
@@ -186,10 +150,12 @@ export class HaMediaSelector extends LitElement {
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${this.value.metadata.thumbnail}
></ha-media-browser-thumbnail>
</div>
`
: html`
<div class="icon-holder image">
@@ -410,13 +376,11 @@ export class HaMediaSelector extends LitElement {
right: 0;
left: 0;
bottom: 0;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
--ha-media-browser-thumbnail-fit: cover;
}
.centered-image {
margin: 4px;
background-size: contain;
--ha-media-browser-thumbnail-fit: contain;
}
.icon-holder {
display: flex;
@@ -96,7 +96,6 @@ export class HaSelectSelector extends LitElement {
.value=${this.value as string | undefined}
@value-changed=${this._selectChanged}
.maxColumns=${this.selector.select?.box_max_columns}
.hass=${this.hass}
></ha-select-box>
${this._renderHelper()}
`;
@@ -199,6 +198,7 @@ export class HaSelectSelector extends LitElement {
: nothing}
<ha-generic-picker
no-sort
.hass=${this.hass}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -215,6 +215,7 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.custom_value) {
return html`
<ha-generic-picker
no-sort
.hass=${this.hass}
.label=${this.label}
.helper=${this.helper}
+63 -37
View File
@@ -1,13 +1,17 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
import "./ha-select";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const DEFAULT_THEME = "default";
const SEARCH_KEYS = [{ name: "primary", weight: 1 }];
@customElement("ha-theme-picker")
export class HaThemePicker extends LitElement {
@property() public value?: string;
@@ -25,52 +29,74 @@ export class HaThemePicker extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "no-theme-label" }) public noThemeLabel?: string;
private _getThemeOptions = memoizeOne(
(
themes: Record<string, unknown>,
locale: string,
includeDefault: boolean
): PickerComboBoxItem[] => {
const items: PickerComboBoxItem[] = [];
if (includeDefault) {
items.push({ id: DEFAULT_THEME, primary: "Home Assistant" });
}
const themeNames = Object.keys(themes).sort((a, b) =>
caseInsensitiveStringCompare(a, b, locale)
);
for (const theme of themeNames) {
items.push({ id: theme, primary: theme });
}
return items;
}
);
private _getItems = () =>
this._getThemeOptions(
this.hass?.themes.themes || {},
this.hass?.locale.language || "en",
this.includeDefault
);
private _valueRenderer = (value: string): TemplateResult =>
html`<span slot="headline"
>${this._getItems().find((i) => i.id === value)?.primary ?? value}</span
>`;
protected render(): TemplateResult {
const options: HaSelectOption[] = Object.keys(
this.hass?.themes.themes || {}
).map((theme) => ({
value: theme,
}));
if (this.includeDefault) {
options.unshift({
value: DEFAULT_THEME,
label: "Home Assistant",
});
}
if (!this.required) {
options.unshift({
value: "remove",
label: this.hass!.localize("ui.components.theme-picker.no_theme"),
});
}
return html`
<ha-select
.label=${this.label ||
this.hass!.localize("ui.components.theme-picker.theme")}
.value=${this.value}
<ha-generic-picker
.label=${this.label ??
this.hass?.localize("ui.components.theme-picker.theme") ??
"Theme"}
.placeholder=${this.noThemeLabel ??
this.hass?.localize("ui.components.theme-picker.no_theme")}
.helper=${this.helper}
.required=${this.required}
.value=${this.value}
.valueRenderer=${this._valueRenderer}
.getItems=${this._getItems}
.searchKeys=${SEARCH_KEYS}
.disabled=${this.disabled}
@selected=${this._changed}
.options=${options}
></ha-select>
.required=${this.required}
@value-changed=${this._changed}
popover-placement="bottom"
></ha-generic-picker>
`;
}
static styles = css`
ha-select {
ha-generic-picker {
width: 100%;
display: block;
}
`;
private _changed(ev: HaSelectSelectEvent): void {
if (!this.hass || ev.detail.value === "") {
return;
}
this.value = ev.detail.value === "remove" ? undefined : ev.detail.value;
private _changed(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
}
+8 -7
View File
@@ -1,24 +1,25 @@
import { mdiLightbulbOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import "./ha-svg-icon";
@customElement("ha-tip")
class HaTip extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
public render() {
if (!this.hass) {
if (!this._localize) {
return nothing;
}
return html`
<ha-svg-icon .path=${mdiLightbulbOutline}></ha-svg-icon>
<span class="prefix"
>${this.hass.localize("ui.panel.config.tips.tip")}</span
>
<span class="prefix">${this._localize("ui.panel.config.tips.tip")}</span>
<span class="text"><slot></slot></span>
`;
}
+223 -57
View File
@@ -1,64 +1,230 @@
import { TopAppBarFixedBase } from "@material/mwc-top-app-bar-fixed/mwc-top-app-bar-fixed-base";
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
export const haTopAppBarFixedStyles = css`
:host {
display: block;
}
.top-app-bar {
box-sizing: border-box;
color: var(--app-header-text-color, #fff);
background-color: var(--app-header-background-color, var(--primary-color));
position: fixed;
top: 0;
inset-inline-end: 0;
width: var(--ha-top-app-bar-width, 100%);
z-index: 4;
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
}
:host([narrow]) .top-app-bar {
padding-left: var(--safe-area-inset-left);
}
.top-app-bar.scrolled:not(.pane-header) {
box-shadow: var(--ha-box-shadow-s);
}
.row {
display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
.section {
display: flex;
align-items: center;
box-sizing: border-box;
min-width: 0;
height: 100%;
padding: 0 var(--ha-space-3);
}
#navigation {
flex: 1 1 auto;
}
.section.center {
flex: 1 1 auto;
justify-content: center;
text-align: center;
}
.section.end {
flex: none;
justify-content: flex-end;
}
.title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--header-height);
padding-inline-start: var(--ha-space-6);
}
:host([narrow]) .title {
padding-inline-start: var(--ha-space-2);
}
.top-app-bar-fixed-adjust {
padding-top: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .top-app-bar-fixed-adjust {
padding-left: var(--safe-area-inset-left);
}
`;
@customElement("ha-top-app-bar-fixed")
export class HaTopAppBarFixed extends TopAppBarFixedBase {
export class HaTopAppBarFixed extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
static override styles = [
styles,
css`
header {
padding-top: var(--safe-area-inset-top);
}
.mdc-top-app-bar__row {
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
.mdc-top-app-bar--fixed-adjust {
padding-top: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .mdc-top-app-bar--fixed-adjust {
padding-left: var(--safe-area-inset-left);
}
.mdc-top-app-bar {
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, var(--mdc-theme-on-primary, #fff));
background-color: var(
--app-header-background-color,
var(--mdc-theme-primary)
);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
}
:host([narrow]) .mdc-top-app-bar {
padding-left: var(--safe-area-inset-left);
}
@media (prefers-reduced-motion: reduce) {
.mdc-top-app-bar {
transition: 1ms;
}
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: var(--ha-space-6);
padding-inline-end: initial;
}
:host([narrow]) .mdc-top-app-bar__title {
padding-inline-start: var(--ha-space-2);
}
`,
];
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@query(".top-app-bar") protected _barElement!: HTMLElement;
private _scrollTarget?: HTMLElement | Window;
@property({ attribute: false })
public get scrollTarget(): HTMLElement | Window {
return this._scrollTarget || window;
}
public set scrollTarget(value: HTMLElement | Window) {
const old = this.scrollTarget;
this._unregisterListeners();
this._scrollTarget = value;
this._updateBarPosition();
this.requestUpdate("scrollTarget", old);
if (this.isConnected) {
this._registerListeners();
this._syncScrollState();
}
}
protected _isPaneHeader(): boolean {
return false;
}
protected render() {
return html`${this._renderHeader()}${this._renderContent()}`;
}
override connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
}
protected _renderHeader() {
const title = html`<span class="title">
<slot name="title"></slot>
</span>`;
const paneHeader = this._isPaneHeader();
return html`
<header
class="top-app-bar ${classMap({
"pane-header": paneHeader,
})}"
>
<div class="row">
${paneHeader
? html`<section class="section" id="title">
<slot name="navigationIcon"></slot>
${title}
</section>`
: nothing}
<section class="section" id="navigation">
${paneHeader
? nothing
: html`<slot name="navigationIcon"></slot> ${this.centerTitle
? nothing
: title}`}
</section>
${!paneHeader && this.centerTitle
? html`<section class="section center">${title}</section>`
: nothing}
<section class="section end" id="actions" role="toolbar">
<slot name="actionItems"></slot>
</section>
</div>
</header>
`;
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust">
<slot></slot>
</div>`;
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
override disconnectedCallback() {
super.disconnectedCallback();
this._unregisterListeners();
}
protected _updateBarPosition() {
if (this._barElement) {
this._barElement.style.position =
this.scrollTarget === window ? "" : "absolute";
}
}
protected _syncScrollState = () => {
const scrollTop =
this.scrollTarget instanceof Window
? this.scrollTarget.pageYOffset
: this.scrollTarget.scrollTop;
this._barElement?.classList.toggle("scrolled", scrollTop > 0);
};
protected _registerListeners() {
this.scrollTarget.addEventListener(
"scroll",
this._syncScrollState,
PASSIVE_EVENT_OPTIONS
);
}
protected _unregisterListeners() {
this.scrollTarget.removeEventListener("scroll", this._syncScrollState);
}
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
}
declare global {
-37
View File
@@ -1,37 +0,0 @@
import { TopAppBarBase } from "@material/mwc-top-app-bar/mwc-top-app-bar-base";
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-top-app-bar")
export class HaTopAppBar extends TopAppBarBase {
static override styles = [
styles,
css`
.mdc-top-app-bar__row {
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
.mdc-top-app-bar--fixed-adjust {
padding-top: calc(var(--safe-area-inset-top) + var(--header-height));
}
.mdc-top-app-bar {
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, var(--mdc-theme-on-primary, #fff));
background-color: var(
--app-header-background-color,
var(--mdc-theme-primary)
);
padding-top: var(--safe-area-inset-top);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-top-app-bar": HaTopAppBar;
}
}
+57 -242
View File
@@ -1,136 +1,37 @@
import {
addHasRemoveClass,
BaseElement,
} from "@material/mwc-base/base-element";
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 type { PropertyValues } from "lit";
import { html, css, nothing } from "lit";
import { property, query, customElement } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { haStyleScrollbar } from "../resources/styles";
import {
HaTopAppBarFixed,
haTopAppBarFixedStyles,
} from "./ha-top-app-bar-fixed";
export const passiveEventOptionsIfSupported = supportsPassiveEventListener
? { passive: true }
: undefined;
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
@customElement("ha-two-pane-top-app-bar-fixed")
export class TopAppBarBaseBase extends BaseElement {
protected override mdcFoundation!: MDCFixedTopAppBarFoundation;
protected override mdcFoundationClass = MDCFixedTopAppBarFoundation;
@query(".mdc-top-app-bar") protected mdcRoot!: HTMLElement;
// _actionItemsSlot should have type HTMLSlotElement, but when TypeScript's
// emitDecoratorMetadata is enabled, the HTMLSlotElement constructor will
// be emitted into the runtime, which will cause an "HTMLSlotElement is
// undefined" error in browsers that don't define it (e.g. IE11).
@query('slot[name="actionItems"]') protected _actionItemsSlot!: HTMLElement;
protected _scrollTarget!: HTMLElement | Window;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@property({ type: Boolean, reflect: true }) prominent = false;
@property({ type: Boolean, reflect: true }) dense = false;
export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
@property({ type: Boolean }) pane = false;
@property({ type: Boolean }) footer = false;
@query(".content") private _contentElement!: HTMLElement;
@query(".content") private _contentElement?: HTMLElement;
@query(".pane .ha-scrollbar") private _paneElement?: HTMLElement;
@property({ attribute: false })
get scrollTarget() {
return this._scrollTarget || window;
protected override _isPaneHeader(): boolean {
return this.pane;
}
set scrollTarget(value) {
this.unregisterListeners();
const old = this.scrollTarget;
this._scrollTarget = value;
this.updateRootPosition();
this.requestUpdate("scrollTarget", old);
this.registerListeners();
}
protected updateRootPosition() {
if (this.mdcRoot) {
const windowScroller = this.scrollTarget === window;
// we add support for top-app-bar's tied to an element scroller.
this.mdcRoot.style.position = windowScroller ? "" : "absolute";
}
}
protected barClasses() {
return {
"mdc-top-app-bar--dense": this.dense,
"mdc-top-app-bar--prominent": this.prominent,
"center-title": this.centerTitle,
"mdc-top-app-bar--fixed": true,
"mdc-top-app-bar--pane": this.pane,
};
}
protected contentClasses() {
return {
"mdc-top-app-bar--fixed-adjust": !this.dense && !this.prominent,
"mdc-top-app-bar--dense-fixed-adjust": this.dense && !this.prominent,
"mdc-top-app-bar--prominent-fixed-adjust": !this.dense && this.prominent,
"mdc-top-app-bar--dense-prominent-fixed-adjust":
this.dense && this.prominent,
"mdc-top-app-bar--pane": this.pane,
};
}
protected override render() {
const title = html`<span class="mdc-top-app-bar__title"
><slot name="title"></slot
></span>`;
protected override _renderContent() {
return html`
<header class="mdc-top-app-bar ${classMap(this.barClasses())}">
<div class="mdc-top-app-bar__row">
${this.pane
? html`<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-start"
id="title"
>
<slot
name="navigationIcon"
@click=${this.handleNavigationClick}
></slot>
${title}
</section>`
: nothing}
<section class="mdc-top-app-bar__section" id="navigation">
${this.pane
? nothing
: html`<slot
name="navigationIcon"
@click=${this.handleNavigationClick}
></slot
>${title}`}
</section>
<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-end"
id="actions"
role="toolbar"
>
<slot name="actionItems"></slot>
</section>
</div>
</header>
<div class=${classMap(this.contentClasses())}>
<div
class=${classMap({
"top-app-bar-fixed-adjust": true,
"top-app-bar-fixed-adjust--pane": this.pane,
})}
>
${this.pane
? html`<div class="pane">
<div class="shadow-container"></div>
@@ -154,117 +55,57 @@ export class TopAppBarBaseBase extends BaseElement {
`;
}
protected updated(changedProperties: PropertyValues<this>) {
protected override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has("pane") && this.hasUpdated) {
this._unregisterListeners();
}
}
protected override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (
changedProperties.has("pane") &&
changedProperties.get("pane") !== undefined
) {
this.unregisterListeners();
this.registerListeners();
this._registerListeners();
this._syncScrollState();
}
}
protected createAdapter(): MDCTopAppBarAdapter {
return {
...addHasRemoveClass(this.mdcRoot),
setStyle: (prprty: string, value: string) =>
this.mdcRoot.style.setProperty(prprty, value),
getTopAppBarHeight: () => this.mdcRoot.clientHeight,
notifyNavigationIconClicked: () => {
this.dispatchEvent(
new Event(strings.NAVIGATION_EVENT, {
bubbles: true,
cancelable: true,
})
);
},
getViewportScrollY: () =>
this.scrollTarget instanceof Window
? this.scrollTarget.pageYOffset
: this.scrollTarget.scrollTop,
getTotalActionItems: () =>
(this._actionItemsSlot as HTMLSlotElement).assignedNodes({
flatten: true,
}).length,
};
}
protected handleTargetScroll = () => {
this.mdcFoundation.handleTargetScroll();
private _handlePaneScroll = (ev: Event) => {
const target = ev.currentTarget as HTMLElement;
target.parentElement?.classList.toggle("scrolled", target.scrollTop > 0);
};
protected handlePaneScroll = (ev) => {
if (ev.target.scrollTop > 0) {
ev.target.parentElement.classList.add("scrolled");
} else {
ev.target.parentElement.classList.remove("scrolled");
}
};
protected handleNavigationClick = () => {
this.mdcFoundation.handleNavigationClick();
};
protected registerListeners() {
protected override _registerListeners() {
if (this.pane) {
this._paneElement!.addEventListener(
this._paneElement?.addEventListener(
"scroll",
this.handlePaneScroll,
passiveEventOptionsIfSupported
this._handlePaneScroll,
PASSIVE_EVENT_OPTIONS
);
this._contentElement.addEventListener(
this._contentElement?.addEventListener(
"scroll",
this.handlePaneScroll,
passiveEventOptionsIfSupported
this._handlePaneScroll,
PASSIVE_EVENT_OPTIONS
);
return;
}
this.scrollTarget.addEventListener(
"scroll",
this.handleTargetScroll,
passiveEventOptionsIfSupported
);
super._registerListeners();
}
protected unregisterListeners() {
this._paneElement?.removeEventListener("scroll", this.handlePaneScroll);
this._contentElement.removeEventListener("scroll", this.handlePaneScroll);
this.scrollTarget.removeEventListener("scroll", this.handleTargetScroll);
}
protected override firstUpdated() {
super.firstUpdated();
this.updateRootPosition();
this.registerListeners();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unregisterListeners();
protected override _unregisterListeners() {
this._paneElement?.removeEventListener("scroll", this._handlePaneScroll);
this._contentElement?.removeEventListener("scroll", this._handlePaneScroll);
super._unregisterListeners();
}
static override styles = [
styles,
haTopAppBarFixedStyles,
haStyleScrollbar,
css`
header {
padding-top: var(--safe-area-inset-top);
}
.mdc-top-app-bar__row {
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
.mdc-top-app-bar--fixed-adjust {
padding-top: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .mdc-top-app-bar--fixed-adjust {
padding-left: var(--safe-area-inset-left);
}
.shadow-container {
position: absolute;
top: calc(-1 * var(--header-height));
@@ -273,39 +114,11 @@ export class TopAppBarBaseBase extends BaseElement {
z-index: 1;
transition: box-shadow 200ms linear;
}
.scrolled .shadow-container {
box-shadow: var(
--mdc-top-app-bar-fixed-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.mdc-top-app-bar {
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, var(--mdc-theme-on-primary, #fff));
background-color: var(
--app-header-background-color,
var(--mdc-theme-primary)
);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
}
:host([narrow]) .mdc-top-app-bar {
padding-left: var(--safe-area-inset-left);
}
@media (prefers-reduced-motion: reduce) {
.mdc-top-app-bar {
transition: 1ms;
}
}
.mdc-top-app-bar--pane.mdc-top-app-bar--fixed-scrolled {
box-shadow: none;
box-shadow: var(--ha-box-shadow-m);
}
#title {
border-right: 1px solid rgba(255, 255, 255, 0.12);
border-inline-end: 1px solid rgba(255, 255, 255, 0.12);
@@ -314,7 +127,8 @@ export class TopAppBarBaseBase extends BaseElement {
flex: 0 0 var(--sidepane-width, 250px);
width: var(--sidepane-width, 250px);
}
div.mdc-top-app-bar--pane {
.top-app-bar-fixed-adjust--pane {
display: flex;
height: calc(
100vh - var(--header-height, 0px) - var(
@@ -323,6 +137,7 @@ export class TopAppBarBaseBase extends BaseElement {
) - var(--safe-area-inset-bottom, 0px)
);
}
.pane {
border-right: 1px solid var(--divider-color);
border-inline-end: 1px solid var(--divider-color);
@@ -334,36 +149,36 @@ export class TopAppBarBaseBase extends BaseElement {
flex-direction: column;
position: relative;
}
.pane .ha-scrollbar {
flex: 1;
}
.pane .footer {
border-top: 1px solid var(--divider-color);
padding-bottom: 8px;
}
.main {
min-height: 100%;
}
.mdc-top-app-bar--pane .main {
.top-app-bar-fixed-adjust--pane .main {
position: relative;
flex: 1;
height: 100%;
}
.mdc-top-app-bar--pane .content {
.top-app-bar-fixed-adjust--pane .content {
height: 100%;
overflow: auto;
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: 24px;
padding-inline-end: initial;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-two-pane-top-app-bar-fixed": TopAppBarBaseBase;
"ha-two-pane-top-app-bar-fixed": HaTwoPaneTopAppBarFixed;
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
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";
import type { Constructor } from "../../types";
/**
* Minimal interface for the inner wa-input / wa-textarea element.
@@ -339,7 +339,7 @@ export const waInputStyles = css`
min-height: var(--ha-space-5);
margin-block-start: 0;
margin-inline-start: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
font-size: var(--ha-font-size-s);
display: flex;
align-items: center;
color: var(--ha-color-text-secondary);
+3
View File
@@ -38,6 +38,9 @@ export class HaListItemBase extends HaRowItem {
public connectedCallback(): void {
super.connectedCallback();
if (!this.hasAttribute("ha-list-item")) {
this.setAttribute("ha-list-item", "");
}
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
+149 -91
View File
@@ -1,9 +1,9 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { haStyleScrollbar } from "../../resources/styles";
import type { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./types";
@@ -45,13 +45,13 @@ export class HaListBase extends LitElement {
/** Host `role` attribute. Empty string means no role is set. */
protected readonly hostRole: string = "list";
private _activeItemIndex = -1;
protected activeItemIndex = -1;
private _firstFocusableIndex = -1;
protected firstFocusableIndex = -1;
private _lastFocusableIndex = -1;
protected lastFocusableIndex = -1;
private _hasFocusableItem = false;
protected hasFocusableItem = false;
private _unbindKeys?: () => void;
@@ -63,22 +63,28 @@ export class HaListBase extends LitElement {
if (!this.hasAttribute("role") && this.hostRole) {
this.setAttribute("role", this.hostRole);
}
this._unbindKeys = tinykeys(this, {
ArrowDown: this._onForward,
ArrowUp: this._onBack,
Home: this._onHome,
End: this._onEnd,
Enter: this._onActivate,
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
this._unbindKeys = tinykeys(
this,
{
ArrowDown: this._onForward,
ArrowUp: this._onBack,
Home: this._onHome,
End: this._onEnd,
PageDown: this._onPageDown,
PageUp: this._onPageUp,
Enter: this.onActivate,
Space: this.onActivate,
},
{ ignore: this._ignoreKeyEvent }
);
this.addEventListener("focusin", this.onFocusIn);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
this.onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
this.onItemUnregister as EventListener
);
}
@@ -86,25 +92,23 @@ export class HaListBase extends LitElement {
super.disconnectedCallback();
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
this.removeEventListener("focusin", this.onFocusIn);
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
this.onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
this.onItemUnregister as EventListener
);
}
public focus(options?: FocusOptions) {
if (!this.items.length) {
if (!this.itemCount) {
super.focus(options);
return;
}
this.focusItemAtIndex(
this._activeItemIndex >= 0 ? this._activeItemIndex : 0
);
this.focusItemAtIndex(this.activeItemIndex >= 0 ? this.activeItemIndex : 0);
}
public focusItemAtIndex(index: number) {
@@ -115,19 +119,19 @@ export class HaListBase extends LitElement {
}
public getActiveItemIndex(): number {
return this._activeItemIndex;
return this.activeItemIndex;
}
public setActiveItemIndex(index: number, focusItem = false) {
if (!this._hasFocusableItem) {
this._activeItemIndex = -1;
if (!this.hasFocusableItem) {
this.activeItemIndex = -1;
return;
}
this._activeItemIndex = Math.max(0, Math.min(this.items.length - 1, index));
if (!this._isFocusable(this._activeItemIndex)) {
this._activeItemIndex = this._firstFocusableIndex;
this.activeItemIndex = Math.max(0, Math.min(this.itemCount - 1, index));
if (!this.isFocusable(this.activeItemIndex)) {
this.activeItemIndex = this.firstFocusableIndex;
}
this._applyActive(focusItem);
this.applyActive(focusItem);
}
/**
@@ -135,18 +139,18 @@ export class HaListBase extends LitElement {
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
this._recomputeFocusableIndexes();
this.recomputeFocusableIndexes();
if (
this._activeItemIndex >= this.items.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
this.activeItemIndex >= this.itemCount ||
!this.hasFocusableItem ||
this.activeItemIndex < 0
) {
this._activeItemIndex = this._firstFocusableIndex;
this.activeItemIndex = this.firstFocusableIndex;
}
this._applyActive(false);
this.applyActive(false);
}
private _onItemRegister = (
protected onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
@@ -160,7 +164,7 @@ export class HaListBase extends LitElement {
this.updateListItems();
};
private _onItemUnregister = (
protected onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
@@ -172,136 +176,190 @@ export class HaListBase extends LitElement {
this.updateListItems();
};
private _recomputeFocusableIndexes() {
protected recomputeFocusableIndexes() {
let first = -1;
let last = -1;
for (let i = 0; i < this.items.length; i++) {
if (this._isFocusable(i)) {
for (let i = 0; i < this.itemCount; i++) {
if (this.isFocusable(i)) {
if (first === -1) {
first = i;
}
last = i;
}
}
this._firstFocusableIndex = first;
this._lastFocusableIndex = last;
this._hasFocusableItem = first !== -1;
this.firstFocusableIndex = first;
this.lastFocusableIndex = last;
this.hasFocusableItem = first !== -1;
}
protected render(): TemplateResult {
return html`<div part="base" class="base">
protected render(): TemplateResult | typeof nothing {
return html`<div part="base" class="base ha-scrollbar">
<slot></slot>
</div>`;
}
private _isFocusable(index: number): boolean {
protected isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;
}
private _applyActive(focusItem: boolean) {
protected applyActive(focusItem: boolean) {
this.items.forEach((item, i) => {
if (!item.interactive || item.disabled) {
item.removeAttribute("tabindex");
return;
}
item.tabIndex = i === this._activeItemIndex ? 0 : -1;
item.tabIndex = i === this.activeItemIndex ? 0 : -1;
});
if (focusItem && this._activeItemIndex >= 0) {
this.items[this._activeItemIndex]?.focus();
if (focusItem && this.activeItemIndex >= 0) {
this.items[this.activeItemIndex]?.focus();
}
}
private _onFocusIn = (ev: FocusEvent) => {
protected onFocusIn = (ev: FocusEvent) => {
const path = ev.composedPath();
for (let i = 0; i < this.items.length; i++) {
if (path.includes(this.items[i])) {
if (i !== this._activeItemIndex) {
this._activeItemIndex = i;
this._applyActive(false);
if (i !== this.activeItemIndex) {
this.activeItemIndex = i;
this.applyActive(false);
}
return;
}
}
};
private _ignoreKeyEvent = (ev: KeyboardEvent): boolean => {
if (ev.repeat && (ev.key === "Enter" || ev.key === " ")) {
return true;
}
if (ev.isComposing) {
return true;
}
const target = ev.target as HTMLElement | null;
// Allow held arrow/Home/End to repeat for continuous navigation
return (
!!target &&
target !== ev.currentTarget &&
target.matches("[contenteditable],input,select,textarea")
);
};
private _onForward = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, 1));
this.moveFocus(ev, this._stepIndex(this.activeItemIndex, 1));
};
private _onBack = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, -1));
this.moveFocus(ev, this._stepIndex(this.activeItemIndex, -1));
};
private _onHome = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._firstFocusableIndex);
this.moveFocus(ev, this.firstFocusableIndex);
};
private _onEnd = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._lastFocusableIndex);
this.moveFocus(ev, this.lastFocusableIndex);
};
private _onActivate = (ev: KeyboardEvent) => {
if (!this._isFocusable(this._activeItemIndex)) {
private _onPageDown = (ev: KeyboardEvent) => {
this.moveFocus(
ev,
this._stepIndex(this.activeItemIndex, 1, this.getPageSize())
);
};
private _onPageUp = (ev: KeyboardEvent) => {
this.moveFocus(
ev,
this._stepIndex(this.activeItemIndex, -1, this.getPageSize())
);
};
/**
* Number of items to jump for PageUp/PageDown. Defaults to 10 (per WAI-ARIA
* Authoring Practices: "moves focus a manageable number of nodes,
* typically 10"). Subclasses with a known viewport (e.g. virtualized lists)
* can override to use the visible page size.
*/
protected getPageSize(): number {
return 10;
}
protected onActivate = (ev: KeyboardEvent) => {
if (!this.isFocusable(this.activeItemIndex)) {
return;
}
ev.preventDefault();
const active = this.items[this._activeItemIndex];
const active = this.items[this.activeItemIndex];
active.activate();
fireEvent(this, "ha-list-activated", {
index: this._activeItemIndex,
index: this.activeItemIndex,
item: active,
});
};
private _moveFocus(ev: KeyboardEvent, next: number) {
if (!this._hasFocusableItem || next < 0 || next === this._activeItemIndex) {
protected moveFocus(ev: KeyboardEvent, next: number) {
if (!this.hasFocusableItem) {
return;
}
ev.preventDefault();
this._activeItemIndex = next;
this._applyActive(true);
if (next < 0 || next === this.activeItemIndex) {
return;
}
this.activeItemIndex = next;
this.applyActive(true);
}
protected get itemCount(): number {
return this.items.length;
}
/**
* Step from `from` by `delta`, skipping non-interactive and disabled items.
* Returns `from` when no other focusable item can be reached (honouring
* `wrapFocus`).
* Pass `count` > 1 to advance by multiple focusable items (PageUp/Down).
* Returns the last focusable index reached, or `from` when none is.
*/
private _stepIndex(from: number, delta: 1 | -1): number {
const n = this.items.length;
if (!n || !this._hasFocusableItem) {
private _stepIndex(from: number, delta: 1 | -1, count = 1): number {
const n = this.itemCount;
if (!n || !this.hasFocusableItem) {
return from;
}
let last = from;
let i = from;
for (let step = 0; step < n; step++) {
let landed = 0;
for (let step = 0; step < n && landed < count; step++) {
i += delta;
if (i < 0 || i >= n) {
if (!this.wrapFocus) {
return from;
return last;
}
i = (i + n) % n;
}
if (this._isFocusable(i)) {
return i;
if (this.isFocusable(i)) {
last = i;
landed++;
}
}
return from;
return last;
}
static styles: CSSResultGroup = css`
:host {
display: block;
}
.base {
display: flex;
flex-direction: column;
gap: var(--ha-list-gap, 0);
padding: var(--ha-list-padding, 0);
margin: 0;
list-style: none;
}
`;
static styles = [
haStyleScrollbar,
css`
:host {
display: block;
}
.base {
display: flex;
flex-direction: column;
gap: var(--ha-list-gap, 0);
padding: var(--ha-list-padding, 0);
margin: 0;
list-style: none;
overflow-x: hidden;
}
`,
];
}
declare global {
+1 -1
View File
@@ -30,7 +30,7 @@ export class HaListNav extends HaListBase {
part="nav"
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<div part="base" class="base ha-scrollbar" role="list">
<slot></slot>
</div>
</nav>`;
@@ -0,0 +1,85 @@
import { property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { Constructor } from "../../types";
import { HaListItemOption } from "../item/ha-list-item-option";
import type { HaListBase } from "./ha-list-base";
export const SelectableMixin = <T extends Constructor<HaListBase>>(
superClass: T
) => {
class SelectableClass extends superClass {
@property({ type: Boolean, reflect: true }) public multi = false;
protected override readonly hostRole = "listbox";
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener("click", this._onOptionClick);
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("click", this._onOptionClick);
}
public updated(changed: Map<string, unknown>) {
super.updated(changed);
if (changed.has("multi")) {
this.setAttribute(
"aria-multiselectable",
this.multi ? "true" : "false"
);
}
}
/** Hook: index of a clicked option element, or `-1` if it's not ours. */
protected optionIndexOf(opt: HaListItemOption): number {
return this.items.indexOf(opt);
}
public clearSelection() {
(this.items as HaListItemOption[]).forEach((opt) => {
if (opt.selected) {
opt.toggleAttribute("selected", false);
}
});
}
private _onOptionClick = (ev: Event) => {
const path = ev.composedPath();
for (const el of path) {
if (el === this) {
return;
}
if (el instanceof HaListItemOption) {
if (el.disabled) {
return;
}
const index = this.optionIndexOf(el);
if (index < 0) {
return;
}
if (this.multi) {
fireEvent(
this,
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
index
);
el.toggleAttribute("selected");
return;
}
if (!el.selected) {
fireEvent(this, "ha-list-item-selected", index);
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
}
}
}
};
}
return SelectableClass;
};
@@ -0,0 +1,66 @@
import { customElement } from "lit/decorators";
import { HaListItemOption } from "../item/ha-list-item-option";
import { SelectableMixin } from "./ha-list-selectable-mixin";
import { HaListVirtualized } from "./ha-list-virtualized";
/**
* @element ha-list-selectable-virtualized
* @extends {HaListVirtualized}
*
* @summary
* Virtualized selection list (role `listbox`). Rows must render
* `<ha-list-item-option>` as their top-level element. Selection is index-based:
* clicking a row fires `ha-list-item-selected` / `ha-list-item-deselected` with
* the row's index, and the row's `selected` attribute is toggled. Consumers own
* the source of truth set each row's `selected` from their own state (for
* example, keyed by the option's `value`) and update it in the event handlers.
*
* Because selection is tracked per-row by the consumer, filtering the visible
* `rows` doesn't affect selections for items outside the current view.
*
* @attr {boolean} multi - Whether multiple options can be selected at once. In
* single-select mode, selecting a row clears any previous selection.
*
* @fires ha-list-item-selected - Fires when the user selects a row.
* `detail` is the row's index (number).
* @fires ha-list-item-deselected - Fires when the user deselects a row (multi-select only).
* `detail` is the row's index (number).
*/
@customElement("ha-list-selectable-virtualized")
export class HaListSelectableVirtualized extends SelectableMixin(
HaListVirtualized
) {
/**
* Hook: maps a clicked option to its absolute index by offsetting its
* position among the rendered (virtualized) children by `rangeStart`.
* Returns `-1` if it's not one of our rows or nothing is rendered yet.
*/
protected optionIndexOf(opt: HaListItemOption): number {
if (!this.virtualizerElement || this.rangeStart === -1) {
return -1;
}
const index = Array.from(this.virtualizerElement.children).indexOf(opt);
if (index === -1) {
return -1;
}
return this.rangeStart + index;
}
/** Deselects every currently rendered (visible) option. */
public clearSelection() {
if (!this.virtualizerElement || this.rangeStart === -1) {
return;
}
Array.from(this.virtualizerElement.children).forEach((opt) => {
if (opt instanceof HaListItemOption && opt.selected) {
opt.toggleAttribute("selected", false);
}
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-selectable-virtualized": HaListSelectableVirtualized;
}
}
+5 -192
View File
@@ -1,8 +1,6 @@
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemOption } from "../item/ha-list-item-option";
import { customElement } from "lit/decorators";
import { HaListBase } from "./ha-list-base";
import type { HaListSelectedDetail } from "./types";
import { SelectableMixin } from "./ha-list-selectable-mixin";
/**
* @element ha-list-selectable
@@ -14,196 +12,11 @@ import type { HaListSelectedDetail } from "./types";
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
*
* @fires ha-list-selected - Fired when the selection changes. `detail: HaListSelectedDetail`.
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
*/
@customElement("ha-list-selectable")
export class HaListSelectable extends HaListBase {
@property({ type: Boolean, reflect: true }) public multi = false;
protected override readonly hostRole = "listbox";
private _selectedIndices?: Set<number>;
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener("click", this._onOptionClick);
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("click", this._onOptionClick);
}
public updated(changed: Map<string, unknown>) {
super.updated(changed);
if (changed.has("multi")) {
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
if (!this.multi && (this._selectedIndices?.size ?? 0) > 1) {
const first = Math.min(...this._selectedIndices!);
this._setSelection(new Set([first]));
}
}
}
/**
* Returns the current selection. `number` (or `-1` if nothing) when single,
* `Set<number>` when multi.
*/
public get selected(): number | Set<number> {
if (this.multi) {
return new Set(this._selectedIndices);
}
return (this._selectedIndices?.size ?? 0) === 0
? -1
: this._selectedIndices!.values().next().value!;
}
public get selectedItems(): HaListItemOption[] {
return this._sortedSelectedIndices()
.map((i) => this.items[i] as HaListItemOption | undefined)
.filter((it): it is HaListItemOption => !!it);
}
/** Replace the entire selection. */
public setSelected(indices: number | number[] | Set<number>): void {
const next =
typeof indices === "number"
? indices < 0
? new Set<number>()
: new Set([indices])
: new Set(indices);
if (!this.multi && next.size > 1) {
const first = Math.min(...next);
this._setSelection(new Set([first]));
return;
}
this._setSelection(next);
}
public select(index: number): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
next.add(index);
this._setSelection(next);
} else {
this._setSelection(new Set([index]));
}
}
public toggle(index: number, force?: boolean): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
const isSelected = next.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
if (shouldSelect) {
next.add(index);
} else {
next.delete(index);
}
this._setSelection(next);
} else {
const isSelected = this._selectedIndices!.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
this._setSelection(shouldSelect ? new Set([index]) : new Set());
}
}
public clearSelection(): void {
this._setSelection(new Set());
}
public updateListItems() {
super.updateListItems();
this._syncItemSelectedState(true);
}
private _sortedSelectedIndices(): number[] {
return [...this._selectedIndices!].sort((a, b) => a - b);
}
private _syncItemSelectedState(reset = false): void {
if (!this._selectedIndices || reset) {
this._selectedIndices = new Set<number>();
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
if (opt.selected) {
this._selectedIndices!.add(i);
}
});
return;
}
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
const shouldBe = this._selectedIndices!.has(i);
if (opt.selected !== shouldBe) {
opt.selected = shouldBe;
}
});
}
private _setSelection(next: Set<number>): void {
const prev = this._selectedIndices!;
const added = new Set<number>();
const removed = new Set<number>();
next.forEach((i) => {
if (!prev.has(i)) {
added.add(i);
}
});
prev.forEach((i) => {
if (!next.has(i)) {
removed.add(i);
}
});
if (!added.size && !removed.size) {
return;
}
this._selectedIndices = next;
this._syncItemSelectedState();
const detail: HaListSelectedDetail = this.multi
? { index: new Set(next), diff: { added, removed } }
: {
index: next.size === 0 ? -1 : next.values().next().value!,
diff: { added, removed },
};
fireEvent(this, "ha-list-selected", detail);
}
private _onOptionClick = (ev: Event) => {
const path = ev.composedPath();
for (const el of path) {
if (el === this) {
return;
}
if (el instanceof HaListItemOption) {
const index = this.items.indexOf(el);
if (index < 0) {
return;
}
const item = this.items[index];
if (item.disabled) {
return;
}
if (this.multi) {
this.toggle(index);
} else {
this.select(index);
}
return;
}
}
};
}
export class HaListSelectable extends SelectableMixin(HaListBase) {}
declare global {
interface HTMLElementTagNameMap {
+356
View File
@@ -0,0 +1,356 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize.js";
import {
css,
html,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { loadVirtualizer } from "../../resources/virtualizer";
import { HaListItemBase } from "../item/ha-list-item-base";
import { HaListBase } from "./ha-list-base";
import type { HaListItemRegistrationDetail } from "./types";
/**
* A single row in a {@link HaListVirtualized}. Identified by a stable `id`
* used as the virtualizer key. Extra fields are passed through to the
* `rowRenderer`.
*/
export interface HaListVirtualizedItem {
/** Stable key used by the virtualizer to track the row across re-renders. */
id: string;
/** Whether the row can be focused and activated. Defaults to `false`. */
interactive?: boolean;
disabled?: boolean;
[key: string]: unknown;
}
/**
* @element ha-list-virtualized
* @extends {HaListBase}
*
* @summary
* Virtualized list. Renders only the rows currently in view to keep large
* lists performant, while preserving the roving-tabindex keyboard navigation
* of {@link HaListBase}.
*
* @csspart base - The scrollable outer container (`<div>`).
*
* @attr {number} pin-index - Row index to scroll to when the list first
* renders. Cleared once the user scrolls.
* @attr {string} pin-block - Block alignment for `pin-index`: `start`,
* `center` (default), `end`, or `nearest`.
*
* @fires ha-list-activated - Fired when a row is activated via Enter/Space. `detail: { index, item }`.
*/
@customElement("ha-list-virtualized")
export class HaListVirtualized extends HaListBase {
@state() private _virtualizerReady = false;
/**
* The list data. Each item is rendered by `rowRenderer`; its `interactive`
* and `disabled` flags determine whether the row is focusable.
*/
@property({ attribute: false })
public rows!: HaListVirtualizedItem[];
/** Renders a single row from its data and index. */
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<HaListVirtualizedItem>;
/** Row index to scroll to on first render (the "pinned" row). */
@property({ attribute: "pin-index", type: Number }) public pinIndex?: number;
/** Block alignment used when scrolling to `pinIndex`. */
@property({ attribute: "pin-block" }) public pinBlock:
| "start"
| "center"
| "end"
| "nearest" = "center";
@state() private _unpinned = false;
@query("lit-virtualizer")
protected virtualizerElement?: LitVirtualizer<HaListVirtualizedItem>;
protected rangeStart = -1;
protected rangeEnd = -1;
private _activeItemFocus = false;
private _scrollToActiveItem = false;
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._loadVirtualizer();
}
if (changedProps.has("rows")) {
this.recomputeFocusableIndexes();
this.activeItemIndex = this.firstFocusableIndex;
}
}
private async _loadVirtualizer() {
await loadVirtualizer();
this._virtualizerReady = true;
}
protected override render(): TemplateResult | typeof nothing {
if (!this._virtualizerReady) {
return nothing;
}
return html`<div part="base" class="base ha-scrollbar">
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="-1"
scroller
.items=${this.rows}
.renderItem=${this.rowRenderer}
style="min-height: 36px; height: 100%;"
.layout=${!this._unpinned && this.pinIndex !== undefined
? {
pin: {
index: this.pinIndex,
block: this.pinBlock,
},
}
: undefined}
@unpinned=${this._handleUnpinned}
@rangeChanged=${this._handleRangeChanged}
>
</lit-virtualizer>
</div>`;
}
/**
* Sets the active (roving-tabindex) row. If the row is outside the rendered
* range it is scrolled into view first, then activated/focused once the
* virtualizer has laid it out.
* @param index - Row index to make active; clamped to the valid range.
* @param focusItem - Whether to move DOM focus to the row.
*/
public setActiveItemIndex(index: number, focusItem = false) {
if (!this.hasFocusableItem) {
this.activeItemIndex = -1;
return;
}
this.activeItemIndex = Math.max(0, Math.min(this.rows.length - 1, index));
if (!this.isFocusable(this.activeItemIndex)) {
this.activeItemIndex = this.firstFocusableIndex;
}
if (
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd
) {
this.applyActive(focusItem);
} else {
this._activeItemFocus = focusItem;
this._scrollToActiveItem = true;
this.virtualizerElement
?.element(index)
?.scrollIntoView({ block: "nearest" });
}
}
/**
* Focuses the row at `index`, scrolling it into view if needed. No-op until
* the virtualizer is ready or when `index` is negative.
*/
public override focusItemAtIndex(index: number) {
if (!this._virtualizerReady || index < 0) {
return;
}
this.setActiveItemIndex(index, true);
}
protected override applyActive(focusItem: boolean) {
if (this.virtualizerElement && this.rangeStart > -1) {
Array.from(this.virtualizerElement.children).forEach((child, index) => {
const el = child as HTMLElement;
if (index + this.rangeStart === this.activeItemIndex) {
el.tabIndex = 0;
if (focusItem) {
el.focus();
}
} else {
el.removeAttribute("tabindex");
}
});
}
}
@eventOptions({ passive: true })
private async _handleRangeChanged(ev: { first: number; last: number }) {
this.rangeStart = ev.first;
this.rangeEnd = ev.last;
await this.virtualizerElement?.layoutComplete;
this._applySetSize();
if (!this.virtualizerElement) {
return;
}
const inRange =
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd;
const focus = this._scrollToActiveItem && inRange && this._activeItemFocus;
this.applyActive(focus);
if (this._scrollToActiveItem && inRange) {
this._activeItemFocus = false;
this._scrollToActiveItem = false;
}
}
// Expose total count + position to assistive tech, since only a slice of
// items is in the DOM at any time.
private _applySetSize() {
if (!this.virtualizerElement || this.rangeStart < 0) {
return;
}
const total = this.rows?.length ?? 0;
Array.from(this.virtualizerElement.children).forEach((child, index) => {
const el = child as HTMLElement;
el.setAttribute("aria-setsize", String(total));
el.setAttribute("aria-posinset", String(this.rangeStart + index + 1));
});
}
protected onFocusIn = (ev: FocusEvent) => {
if (
!this.virtualizerElement ||
this.rangeStart === -1 ||
this.rangeEnd === -1
) {
return;
}
const path = ev.composedPath();
const children = Array.from(this.virtualizerElement.children);
for (let i = this.rangeStart; i <= this.rangeEnd; i++) {
if (path.includes(children[i - this.rangeStart])) {
if (i !== this.activeItemIndex) {
this.activeItemIndex = i;
if (i < this.rangeStart || i > this.rangeEnd) {
this._activeItemFocus = true;
this._scrollToActiveItem = true;
this.virtualizerElement
?.element(this.activeItemIndex)
?.scrollIntoView({ block: "nearest" });
} else {
this.applyActive(false);
}
}
return;
}
}
};
protected override onActivate = (ev: KeyboardEvent) => {
if (!this.isFocusable(this.activeItemIndex)) {
return;
}
if (
this.virtualizerElement &&
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd
) {
const active = this.virtualizerElement?.children[
this.activeItemIndex - this.rangeStart
] as HaListItemBase | undefined;
if (active && active instanceof HaListItemBase) {
ev.preventDefault();
active.activate();
fireEvent(this, "ha-list-activated", {
index: this.activeItemIndex,
item: active,
});
}
}
};
protected isFocusable(index: number): boolean {
const item = this.rows[index];
if (!item) {
return false;
}
const { disabled = false, interactive = false } = this.rows[index];
return interactive && !disabled;
}
protected override get itemCount(): number {
return this.rows?.length ?? 0;
}
protected override moveFocus(ev: KeyboardEvent, next: number) {
if (!this.hasFocusableItem) {
return;
}
ev.preventDefault();
if (next < 0 || next === this.activeItemIndex) {
return;
}
this.activeItemIndex = next;
if (next < this.rangeStart || next > this.rangeEnd) {
this._activeItemFocus = true;
this._scrollToActiveItem = true;
this.virtualizerElement?.element(this.activeItemIndex)?.scrollIntoView({
block: "nearest",
});
} else {
this.applyActive(true);
}
}
protected override getPageSize(): number {
if (this.rangeStart < 0 || this.rangeEnd < 0) {
return super.getPageSize();
}
return Math.max(1, this.rangeEnd - this.rangeStart + 1);
}
private _keyFunction = (item: HaListVirtualizedItem) => item.id;
@eventOptions({ passive: true })
private _handleUnpinned() {
this._unpinned = true;
}
protected override onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
};
protected override onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
// ignore
};
static styles = [
...HaListBase.styles,
css`
.base {
height: 100%;
}
[ha-list-item] {
width: 100%;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-virtualized": HaListVirtualized;
}
}
+2 -7
View File
@@ -1,11 +1,5 @@
import type { HaListItemBase } from "../item/ha-list-item-base";
export interface HaListSelectedDetail {
index: number | Set<number>;
diff?: { added: Set<number>; removed: Set<number> };
value?: string | string[];
}
export interface HaListActivatedDetail {
index: number;
item: HaListItemBase;
@@ -17,7 +11,8 @@ export interface HaListItemRegistrationDetail {
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-item-selected": number;
"ha-list-item-deselected": number;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
@@ -227,7 +227,7 @@ class DialogMediaManage extends LitElement {
</ha-list>
`}
${isComponentLoaded(this.hass.config, "hassio")
? html`<ha-tip .hass=${this.hass}>
? html`<ha-tip>
${this.hass.localize(
"ui.components.media-browser.file_management.tip_media_storage",
{
@@ -0,0 +1,147 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
const SMALL_THUMBNAIL_THRESHOLD = 16;
const isSvgUrl = (url: string): boolean =>
/\.svg(\?|#|$)/i.test(url) || url.startsWith("data:image/svg+xml");
const resolveThumbnailURL = (
hass: HomeAssistant,
thumbnailUrl: string
): Promise<string> => {
if (isBrandUrl(thumbnailUrl)) {
return Promise.resolve(
brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: hass.themes?.darkMode,
},
hass.auth.data.hassUrl
)
);
}
if (thumbnailUrl.startsWith("/")) {
// Local thumbnails require authentication; fetch and inline as base64.
return hass
.fetchWithAuth(thumbnailUrl)
.then((response) => response.blob())
.then(
(blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
})
);
}
return Promise.resolve(thumbnailUrl);
};
@customElement("ha-media-browser-thumbnail")
export class HaMediaBrowserThumbnail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public url?: string;
@state() private _resolvedUrl?: string;
@state() private _small = false;
@state() private _brand = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("url")) {
this._resolve();
}
}
private async _resolve(): Promise<void> {
this._small = false;
this._brand = !!this.url && isBrandUrl(this.url);
if (!this.url) {
this._resolvedUrl = undefined;
return;
}
const requested = this.url;
try {
const resolved = await resolveThumbnailURL(this.hass, requested);
if (requested !== this.url) return;
this._resolvedUrl = resolved;
this._probeSize(resolved);
} catch (_err) {
if (requested === this.url) this._resolvedUrl = undefined;
}
}
private _probeSize(url: string): void {
// SVGs (including brand icons) scale natively; pixelated rendering would
// break vector output.
if (this.url && isBrandUrl(this.url)) return;
if (isSvgUrl(url)) return;
const img = new Image();
img.addEventListener("load", () => {
if (this._resolvedUrl !== url) return;
if (
img.naturalWidth > 0 &&
img.naturalWidth <= SMALL_THUMBNAIL_THRESHOLD
) {
this._small = true;
}
});
img.src = url;
}
protected render(): TemplateResult | typeof nothing {
if (!this._resolvedUrl) return nothing;
return html`
<div
class=${classMap({
image: true,
small: this._small,
brand: this._brand,
})}
style="background-image: url(${this._resolvedUrl})"
></div>
`;
}
static readonly styles: CSSResultGroup = css`
:host {
display: block;
width: 100%;
height: 100%;
}
.image {
width: 100%;
height: 100%;
background-size: var(--ha-media-browser-thumbnail-fit, contain);
background-repeat: no-repeat;
background-position: center;
}
.image.brand {
background-size: 40%;
}
.image.small {
image-rendering: pixelated;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-browser-thumbnail": HaMediaBrowserThumbnail;
}
}
@@ -13,7 +13,6 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { slugify } from "../../common/string/slugify";
import { debounce } from "../../common/util/debounce";
@@ -39,11 +38,6 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url";
import "../entity/ha-entity-picker";
import "../ha-alert";
@@ -52,6 +46,7 @@ import "../ha-card";
import "../ha-icon-button";
import "../ha-list";
import "../ha-list-item";
import "./ha-media-browser-thumbnail";
import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tooltip";
@@ -411,12 +406,6 @@ export class HaMediaPlayerBrowse extends LitElement {
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
const backgroundImage = currentItem.thumbnail
? this._getThumbnailURLorBase64(currentItem.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
${
currentItem.can_play
@@ -431,13 +420,11 @@ export class HaMediaPlayerBrowse extends LitElement {
<div class="header-content">
${currentItem.thumbnail
? html`
<div
class="img"
style="background-image: ${until(
backgroundImage,
""
)}"
>
<div class="img">
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${currentItem.thumbnail}
></ha-media-browser-thumbnail>
${this.narrow &&
currentItem?.can_play &&
(!this.accept ||
@@ -638,12 +625,6 @@ export class HaMediaPlayerBrowse extends LitElement {
}
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
const backgroundImage = child.thumbnail
? this._getThumbnailURLorBase64(child.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
<div class="child" .item=${child} @click=${this._childClicked}>
<ha-card outlined>
@@ -655,10 +636,13 @@ export class HaMediaPlayerBrowse extends LitElement {
"centered-image": ["app", "directory"].includes(
child.media_class
),
"brand-image": isBrandUrl(child.thumbnail),
})} image"
style="background-image: ${until(backgroundImage, "")}"
></div>
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${child.thumbnail}
></ha-media-browser-thumbnail>
</div>
`
: html`
<div class="icon-holder image">
@@ -703,13 +687,7 @@ export class HaMediaPlayerBrowse extends LitElement {
private _renderListItem = (child: MediaPlayerItem): TemplateResult => {
const currentItem = this._currentItem;
const mediaClass = MediaClassBrowserSettings[currentItem!.media_class];
const backgroundImage =
mediaClass.show_list_images && child.thumbnail
? this._getThumbnailURLorBase64(child.thumbnail).then(
(value) => `url(${value})`
)
: "none";
const showImage = mediaClass.show_list_images && !!child.thumbnail;
return html`
<ha-list-item
@@ -717,7 +695,7 @@ export class HaMediaPlayerBrowse extends LitElement {
.item=${child}
.graphic=${mediaClass.show_list_images ? "medium" : "avatar"}
>
${backgroundImage === "none" && !child.can_play
${!showImage && !child.can_play
? html`<ha-svg-icon
.path=${MediaClassBrowserSettings[
child.media_class === "directory"
@@ -731,9 +709,14 @@ export class HaMediaPlayerBrowse extends LitElement {
graphic: true,
thumbnail: mediaClass.show_list_images === true,
})}
style="background-image: ${until(backgroundImage, "")}"
slot="graphic"
>
${showImage
? html`<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${child.thumbnail}
></ha-media-browser-thumbnail>`
: nothing}
${child.can_play
? html`<ha-icon-button
class="play ${classMap({
@@ -753,51 +736,6 @@ export class HaMediaPlayerBrowse extends LitElement {
`;
};
private async _getThumbnailURLorBase64(
thumbnailUrl: string | undefined
): Promise<string> {
if (!thumbnailUrl) {
return "";
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
return brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
}
if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication
return new Promise((resolve, reject) => {
this.hass
.fetchWithAuth(thumbnailUrl!)
// Since we are fetching with an authorization header, we cannot just put the
// URL directly into the document; we need to embed the image. We could do this
// using blob URLs, but then we would need to keep track of them in order to
// release them properly. Instead, we embed the thumbnail using base64.
.then((response) => response.blob())
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
resolve(typeof result === "string" ? result : "");
};
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
});
});
}
return thumbnailUrl;
}
private _actionClicked = (ev: MouseEvent): void => {
ev.stopPropagation();
const item = (ev.currentTarget as any).item;
@@ -1048,14 +986,20 @@ export class HaMediaPlayerBrowse extends LitElement {
align-items: flex-start;
}
.header-content .img {
position: relative;
height: 175px;
width: 175px;
margin-right: 16px;
background-size: cover;
border-radius: 2px;
overflow: hidden;
transition:
width 0.4s,
height 0.4s;
--ha-media-browser-thumbnail-fit: cover;
}
.header-content .img ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
.header-info {
display: flex;
@@ -1191,18 +1135,12 @@ export class HaMediaPlayerBrowse extends LitElement {
right: 0;
left: 0;
bottom: 0;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
--ha-media-browser-thumbnail-fit: cover;
}
.centered-image {
margin: 0 8px;
background-size: contain;
}
.brand-image {
background-size: 40%;
--ha-media-browser-thumbnail-fit: contain;
}
.children ha-card .icon-holder {
@@ -1278,17 +1216,21 @@ export class HaMediaPlayerBrowse extends LitElement {
}
ha-list-item .graphic {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
position: relative;
border-radius: var(--ha-border-radius-sm);
display: flex;
align-content: center;
align-items: center;
overflow: hidden;
line-height: initial;
}
ha-list-item .graphic ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
ha-list-item .graphic .play {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: all 0.5s;
background-color: rgba(var(--rgb-card-background-color), 0.5);
+2
View File
@@ -99,6 +99,8 @@ export class HaRadioOption extends Radio {
--ha-radio-option-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
color: var(--checked-icon-color);
border-color: var(--checked-icon-color);
}
[part~="label"] {
+1
View File
@@ -112,6 +112,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px 0;
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+4 -4
View File
@@ -95,7 +95,7 @@ export interface TriggerList {
export interface BaseTrigger {
alias?: string;
comment?: string;
note?: string;
/** @deprecated Use `trigger` instead */
platform?: string;
trigger: string;
@@ -241,7 +241,7 @@ export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
interface BaseCondition {
condition: string;
alias?: string;
comment?: string;
note?: string;
enabled?: boolean;
options?: Record<string, unknown>;
}
@@ -609,7 +609,7 @@ export interface AutomationClipboard {
export interface BaseSidebarConfig {
delete: () => void;
close: (focus?: boolean) => void;
editComment: () => void;
editNote: () => void;
}
export interface TriggerSidebarConfig extends BaseSidebarConfig {
@@ -671,7 +671,7 @@ export interface OptionSidebarConfig extends BaseSidebarConfig {
rename: () => void;
duplicate: () => void;
defaultOption?: boolean;
comment?: string;
note?: string;
}
export interface ScriptFieldSidebarConfig extends BaseSidebarConfig {
+11 -2
View File
@@ -17,6 +17,7 @@ export interface BluetoothDeviceData extends DataTableRowData {
source: string;
time: number;
tx_power: number;
raw: string | null;
}
export interface BluetoothConnectionData extends DataTableRowData {
@@ -58,13 +59,21 @@ export interface BluetoothAllocationsData {
allocated: string[];
}
export type BluetoothScannerMode = "active" | "passive";
export type BluetoothScannerRequestedMode = BluetoothScannerMode | "auto";
export interface BluetoothScannerState {
source: string;
adapter: string;
current_mode: "active" | "passive" | null;
requested_mode: "active" | "passive" | null;
current_mode: BluetoothScannerMode | null;
requested_mode: BluetoothScannerRequestedMode | null;
}
export const isScannerStateMismatch = (state: BluetoothScannerState): boolean =>
state.requested_mode !== "auto" &&
state.current_mode !== state.requested_mode;
export const subscribeBluetoothScannersDetailsUpdates = (
conn: Connection,
store: Store<BluetoothScannersDetails>
-1
View File
@@ -40,7 +40,6 @@ export const createConfigFlow = (
"config/config_entries/flow",
{
handler,
show_advanced_options: Boolean(hass.userData?.showAdvanced),
entry_id,
},
HEADERS
+4 -1
View File
@@ -5,7 +5,10 @@ export interface DataTableFilter {
export type DataTableFilters = Record<string, DataTableFilter>;
export type DataTableFiltersValue = string[] | { key: string[] } | undefined;
export type DataTableFiltersValue =
| string[]
| Record<"key" | string, string[]>
| undefined;
export type DataTableFiltersValues = Record<string, DataTableFiltersValue>;
+1 -1
View File
@@ -13,7 +13,7 @@ import {
export interface DeviceAutomation {
alias?: string;
comment?: string;
note?: string;
device_id: string;
domain: string;
entity_id?: string;
+70 -3
View File
@@ -2,16 +2,23 @@ import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
import type { ConfigEntry } from "../config_entries";
import type { HaEntityPickerEntityFilterFunc } from "../entity/entity";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../entity/entity_registry";
import { domainToName } from "../integration";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "./device_registry";
export interface DevicePickerItem extends PickerComboBoxItem {
@@ -19,6 +26,46 @@ export interface DevicePickerItem extends PickerComboBoxItem {
domain_name?: string;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
}
export const computeDeviceAreaLabel = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"],
states: HomeAssistant["states"],
localize: LocalizeFunc,
language: HomeAssistant["language"],
translationMetadata: HomeAssistant["translationMetadata"],
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
): DeviceAreaLabel => {
const area = getDeviceArea(device, areas);
const viaDevice = device.via_device_id
? devices[device.via_device_id]
: undefined;
const viaDeviceName = viaDevice
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
: undefined;
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : undefined;
const viaDeviceAreaName = viaDeviceArea
? computeAreaName(viaDeviceArea)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
? computeAreaName(area)
: viaDeviceAreaName
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.deviceName",
@@ -36,6 +83,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.domain",
weight: 4,
},
{
name: "search_labels.viaDeviceName",
weight: 3,
},
{
name: "search_labels.viaDeviceArea",
weight: 3,
},
];
export const getDevices = (
@@ -149,9 +204,19 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const area = getDeviceArea(device, hass.areas);
const areaName = area ? computeAreaName(area) : undefined;
const { areaName, viaDeviceName, viaDeviceAreaName } =
computeDeviceAreaLabel(
device,
hass.areas,
hass.devices,
hass.states,
hass.localize,
hass.language,
hass.translationMetadata,
device.via_device_id
? deviceEntityLookup[device.via_device_id]
: undefined
);
const configEntry = device.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
@@ -174,6 +239,8 @@ export const getDevices = (
areaName: areaName || null,
domain: domain || null,
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
+7 -1
View File
@@ -161,6 +161,10 @@ export interface VacuumEntityOptions {
last_seen_segments?: Segment[];
}
export interface DeviceTrackerEntityOptions {
associated_zone?: string | null;
}
export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
@@ -172,6 +176,7 @@ export interface EntityRegistryOptions {
cover?: CoverEntityOptions;
valve?: ValveEntityOptions;
vacuum?: VacuumEntityOptions;
device_tracker?: DeviceTrackerEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
conversation?: Record<string, unknown>;
"cloud.alexa"?: Record<string, unknown>;
@@ -197,7 +202,8 @@ export interface EntityRegistryEntryUpdateParams {
| LightEntityOptions
| CoverEntityOptions
| ValveEntityOptions
| VacuumEntityOptions;
| VacuumEntityOptions
| DeviceTrackerEntityOptions;
aliases?: (string | null)[];
labels?: string[];
categories?: Record<string, string | null>;
-1
View File
@@ -2,7 +2,6 @@ import type { Connection } from "home-assistant-js-websocket";
import type { ShortcutItem } from "./home_shortcuts";
export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
+12
View File
@@ -1,6 +1,14 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { LovelaceCardFeatureContext } from "../panels/lovelace/card-features/types";
import type { LovelaceCardConfig } from "./lovelace/config/card";
export interface CustomCardSuggestion<
T extends LovelaceCardConfig = LovelaceCardConfig,
> {
label?: string;
config: T;
}
export interface CustomCardEntry {
type: string;
@@ -8,6 +16,10 @@ export interface CustomCardEntry {
description?: string;
preview?: boolean;
documentationURL?: string;
getEntitySuggestion?: (
hass: HomeAssistant,
entityId: string
) => CustomCardSuggestion | CustomCardSuggestion[] | null;
}
export interface CustomBadgeEntry {
+16 -5
View File
@@ -2,7 +2,10 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { navigate } from "../common/navigate";
import type { HomeAssistant } from "../types";
import { subscribeDeviceRegistry } from "./device/device_registry";
import {
subscribeDeviceRegistry,
type DeviceRegistryEntry,
} from "./device/device_registry";
import { getThreadDataSetTLV, listThreadDataSets } from "./thread";
export enum NetworkType {
@@ -77,9 +80,9 @@ export const startExternalCommissioning = async (hass: HomeAssistant) => {
});
};
export const redirectOnNewMatterDevice = (
export const watchForNewMatterDevice = (
hass: HomeAssistant,
callback?: () => void
callback: (device: DeviceRegistryEntry) => void
): UnsubscribeFunc => {
let curMatterDevices: Set<string> | undefined;
const unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
@@ -101,8 +104,7 @@ export const redirectOnNewMatterDevice = (
if (newMatterDevices.length) {
unsubDeviceReg();
curMatterDevices = undefined;
callback?.();
navigate(`/config/devices/device/${newMatterDevices[0].id}`);
callback(newMatterDevices[0]);
}
});
return () => {
@@ -111,6 +113,15 @@ export const redirectOnNewMatterDevice = (
};
};
export const redirectOnNewMatterDevice = (
hass: HomeAssistant,
callback?: () => void
): UnsubscribeFunc =>
watchForNewMatterDevice(hass, (device) => {
callback?.();
navigate(`/config/devices/device/${device.id}`);
});
export const addMatterDevice = (hass: HomeAssistant) => {
startExternalCommissioning(hass);
};
+3 -3
View File
@@ -15,7 +15,7 @@ import {
mdiPlaylistMusic,
mdiPlayPause,
mdiPodcast,
mdiPower,
mdiPowerStandby,
mdiPowerOff,
mdiPowerOn,
mdiRepeat,
@@ -295,7 +295,7 @@ export const computeMediaControls = (
return supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
? [
{
icon: mdiPower,
icon: mdiPowerStandby,
action: "turn_on",
},
]
@@ -316,7 +316,7 @@ export const computeMediaControls = (
if (supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)) {
buttons.push({
icon: assumedState ? mdiPowerOff : mdiPower,
icon: assumedState ? mdiPowerOff : mdiPowerStandby,
action: "turn_off",
});
}
-1
View File
@@ -7,7 +7,6 @@ export const createOptionsFlow = (hass: HomeAssistant, handler: string) =>
"config/config_entries/options/flow",
{
handler,
show_advanced_options: Boolean(hass.userData?.showAdvanced),
}
);
+3 -3
View File
@@ -36,7 +36,7 @@ export const isMaxMode = arrayLiteralIncludes(MODES_MAX);
export const baseActionStruct = object({
alias: optional(string()),
comment: optional(string()),
note: optional(string()),
continue_on_error: optional(boolean()),
enabled: optional(boolean()),
});
@@ -106,7 +106,7 @@ export interface Field {
interface BaseAction {
alias?: string;
comment?: string;
note?: string;
continue_on_error?: boolean;
enabled?: boolean;
}
@@ -197,7 +197,7 @@ export interface ForEachRepeat extends BaseRepeat {
export interface Option {
alias?: string;
comment?: string;
note?: string;
conditions: string | Condition[];
sequence: Action | Action[];
}
+1 -1
View File
@@ -125,7 +125,7 @@ export interface BooleanSelector {
boolean: {} | null;
}
export type AutomationBehaviorTriggerMode = "first" | "last" | "any";
export type AutomationBehaviorTriggerMode = "first" | "all" | "each";
export type AutomationBehaviorConditionMode = "all" | "any";
-1
View File
@@ -16,7 +16,6 @@ export const createSubConfigFlow = (
"config/config_entries/subentries/flow",
{
handler: [configEntryId, subFlowType],
show_advanced_options: Boolean(hass.userData?.showAdvanced),
subentry_id,
},
HEADERS
@@ -167,7 +167,6 @@ export interface DataEntryFlowDialogParams {
entryId?: string;
}) => void;
flowConfig: FlowConfig;
showAdvanced?: boolean;
dialogParentElement?: HTMLElement;
navigateToResult?: boolean;
carryOverDevices?: string[];
@@ -48,7 +48,6 @@ class StepFlowAbort extends LitElement {
showConfigFlowDialog(this.params.dialogParentElement!, {
dialogClosedCallback: this.params.dialogClosedCallback,
startFlowHandler: this.handler,
showAdvanced: this.hass.userData?.showAdvanced,
navigateToResult: this.params.navigateToResult,
});
},
+3
View File
@@ -3,6 +3,7 @@ import type { LocalizeFunc } from "../../common/translations/localize";
import { createSearchParam } from "../../common/url/search-params";
import type { SingleHassServiceTarget } from "../../data/target";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
@@ -105,6 +106,8 @@ export function addToActionHandler(
searchParams[ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM] = target.entity_id;
} else if (target.device_id) {
searchParams[ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM] = target.device_id;
} else if (target.area_id) {
searchParams[ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM] = target.area_id;
}
const params = (addElement: string) =>
@@ -206,8 +206,6 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
static styles = css`
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--primary-background-color);
--header-bar-padding: var(--safe-area-inset-top, 0px) 0 0
var(--safe-area-inset-left, 0px);
border-bottom: 1px solid var(--divider-color);
+1 -1
View File
@@ -267,7 +267,7 @@ export class QuickBar extends LitElement {
></ha-picker-combo-box>`
: nothing}
${this._showHint
? html`<ha-tip slot="footer" .hass=${this.hass}
? html`<ha-tip slot="footer"
>${this.hass.localize("ui.tips.key_shortcut_quick_search", {
keyboard_shortcut: html`<button
class="link"
@@ -147,7 +147,6 @@ class DialogEditSidebar extends LitElement {
return html`
<ha-items-display-editor
.hass=${this.hass}
.value=${{
order: this._order,
hidden: hiddenPanels,
@@ -343,9 +343,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this._step = this._previousSteps.pop()!;
}
private _goToNextStep(ev?: CustomEvent) {
private async _goToNextStep(ev?: CustomEvent) {
if (ev?.detail?.updateConfig) {
this._fetchAssistConfiguration();
await this._fetchAssistConfiguration();
}
if (ev?.detail?.nextStep) {
this._nextStep = ev.detail.nextStep;
+2 -2
View File
@@ -153,7 +153,7 @@ export class HomeAssistantMain extends LitElement {
/* remove the grey tap highlights in iOS on the fullscreen touch targets */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
--ha-sidebar-width: calc(56px + var(--safe-area-inset-left, 0px));
--mdc-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
--safe-area-content-inset-left: 0px;
--safe-area-content-inset-right: var(--safe-area-inset-right);
}
@@ -162,7 +162,7 @@ export class HomeAssistantMain extends LitElement {
}
:host([modal]) {
--ha-sidebar-width: unset;
--mdc-top-app-bar-width: unset;
--ha-top-app-bar-width: 100%;
--safe-area-content-inset-left: var(--safe-area-inset-left);
}
partial-panel-resolver,
+7 -7
View File
@@ -1,8 +1,8 @@
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { listenMediaQuery } from "../common/dom/media_query";
import type { Constructor } from "../types";
import { isMobileClient } from "../util/is_mobile";
import { listenMediaQuery } from "../common/dom/media_query";
export const MobileAwareMixin = <T extends Constructor<LitElement>>(
superClass: T
@@ -12,16 +12,16 @@ export const MobileAwareMixin = <T extends Constructor<LitElement>>(
protected _isMobileClient = isMobileClient;
protected mobileSizeQuery =
"all and (max-width: 450px), all and (max-height: 500px)";
private _unsubMql?: () => void;
public connectedCallback() {
super.connectedCallback();
this._unsubMql = listenMediaQuery(
"all and (max-width: 450px), all and (max-height: 500px)",
(matches) => {
this._isMobileSize = matches;
}
);
this._unsubMql = listenMediaQuery(this.mobileSizeQuery, (matches) => {
this._isMobileSize = matches;
});
}
public disconnectedCallback() {
+55 -23
View File
@@ -1,18 +1,25 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { LOCAL_TIME_ZONE } from "../common/datetime/resolve-time-zone";
import memoizeOne from "memoize-one";
import {
HAS_RESOLVED_IANA_TIME_ZONE,
LOCAL_TIME_ZONE,
} from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-button";
import { COUNTRIES } from "../components/ha-country-picker";
import "../components/ha-form/ha-form";
import type { HaForm } from "../components/ha-form/ha-form";
import type { HaFormSchema } from "../components/ha-form/types";
import "../components/ha-spinner";
import type { ConfigUpdateValues } from "../data/core";
import { saveCoreConfig } from "../data/core";
import { countryCurrency } from "../data/currency";
import { onboardCoreConfigStep } from "../data/onboarding";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import "./onboarding-location";
@@ -28,7 +35,9 @@ class OnboardingCoreConfig extends LitElement {
private _elevation = "0";
private _timeZone: ConfigUpdateValues["time_zone"] = LOCAL_TIME_ZONE;
@state() private _timeZone: ConfigUpdateValues["time_zone"] = LOCAL_TIME_ZONE;
@state() private _timeZoneDetected = HAS_RESOLVED_IANA_TIME_ZONE;
private _language: ConfigUpdateValues["language"] = getLocalLanguage();
@@ -42,7 +51,29 @@ class OnboardingCoreConfig extends LitElement {
@state() private _skipCore = false;
@query("ha-country-picker") private _countryPicker?: HTMLElement;
@query("ha-form") private _form?: HaForm;
private _schema = memoizeOne((includeTimeZone: boolean): HaFormSchema[] => [
{
name: "country",
required: true,
selector: { country: null },
},
...(includeTimeZone
? ([
{
name: "time_zone",
required: true,
selector: { timezone: null },
},
] satisfies HaFormSchema[])
: []),
]);
private _computeLabel = (schema: HaFormSchema) =>
this.hass.localize(
`ui.panel.config.core.section.core.core_config.${schema.name}` as any
);
protected render(): TemplateResult {
if (!this._location) {
@@ -68,17 +99,17 @@ class OnboardingCoreConfig extends LitElement {
)}
</p>
<ha-country-picker
class="flex"
<ha-form
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.country"
) || "Country"}
required
.data=${{
country: this._country ?? "",
time_zone: this._timeZone,
}}
.schema=${this._schema(!this._timeZoneDetected)}
.computeLabel=${this._computeLabel}
.disabled=${this._working}
.value=${this._countryValue}
@value-changed=${this._handleCountryChanged}
></ha-country-picker>
@value-changed=${this._handleFormChanged}
></ha-form>
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._working}>
@@ -99,12 +130,12 @@ class OnboardingCoreConfig extends LitElement {
});
}
private get _countryValue() {
return this._country || "";
}
private _handleCountryChanged(ev: ValueChangedEvent<string>) {
this._country = ev.detail.value;
private _handleFormChanged(ev: CustomEvent) {
const value = ev.detail.value as { country?: string; time_zone?: string };
this._country = value.country || undefined;
if (value.time_zone) {
this._timeZone = value.time_zone;
}
}
private async _locationChanged(ev) {
@@ -123,11 +154,12 @@ class OnboardingCoreConfig extends LitElement {
}
if (ev.detail.value.timezone) {
this._timeZone = ev.detail.value.timezone;
this._timeZoneDetected = true;
}
if (ev.detail.value.unit_system) {
this._unitSystem = ev.detail.value.unit_system;
}
if (this._country) {
if (this._country && this._timeZoneDetected) {
this._skipCore = true;
this._save(ev);
return;
@@ -145,11 +177,11 @@ class OnboardingCoreConfig extends LitElement {
fireEvent(this, "onboarding-progress", { increase: 0.5 });
await this.updateComplete;
setTimeout(() => this._countryPicker!.focus(), 100);
setTimeout(() => this._form?.focus(), 100);
}
private async _save(ev) {
if (!this._location || !this._country) {
if (!this._location || !this._country || !this._timeZone) {
return;
}
ev.preventDefault();
@@ -166,7 +198,7 @@ class OnboardingCoreConfig extends LitElement {
this._unitSystem || ["US", "MM", "LR"].includes(this._country)
? "us_customary"
: "metric",
time_zone: this._timeZone || "UTC",
time_zone: this._timeZone,
currency: this._currency || countryCurrency[this._country] || "EUR",
country: this._country,
language: this._language,
+14 -11
View File
@@ -190,16 +190,20 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
.label=${this.hass.localize("ui.common.refresh")}
@click=${this._handleRefresh}
></ha-icon-button>
${showPane && this.hass.user?.is_admin
? html`<ha-list slot="pane" multi}>${calendarItems}</ha-list>
<ha-list-item
graphic="icon"
slot="pane-footer"
@click=${this._addCalendar}
>
<ha-svg-icon .path=${mdiPlus} slot="graphic"></ha-svg-icon>
${this.hass.localize("ui.components.calendar.create_calendar")}
</ha-list-item>`
${showPane
? html`<ha-list slot="pane" multi>${calendarItems}</ha-list>${this
.hass.user?.is_admin
? html`<ha-list-item
graphic="icon"
slot="pane-footer"
@click=${this._addCalendar}
>
<ha-svg-icon .path=${mdiPlus} slot="graphic"></ha-svg-icon>
${this.hass.localize(
"ui.components.calendar.create_calendar"
)}
</ha-list-item>`
: nothing}`
: nothing}
<ha-full-calendar
add-fab
@@ -338,7 +342,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
private _addCalendar = async (): Promise<void> => {
showConfigFlowDialog(this, {
startFlowHandler: "local_calendar",
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, "local_calendar"),
dialogClosedCallback: ({ flowFinished }) => {
if (flowFinished) {
+3 -3
View File
@@ -175,7 +175,7 @@ class PanelClimate extends LitElement {
position: fixed;
top: 0;
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
@@ -191,7 +191,7 @@ class PanelClimate extends LitElement {
}
:host([narrow]) .header {
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
@@ -200,7 +200,7 @@ class PanelClimate extends LitElement {
}
:host([scrolled]) .header {
box-shadow: var(
--mdc-top-app-bar-fixed-box-shadow,
--bar-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
@@ -112,7 +112,6 @@ export class HaConfigApplicationCredentials extends LitElement {
showNarrow: true,
template: (credential) => html`
<ha-icon-overflow-menu
.hass=${this.hass}
narrow
.items=${[
{
@@ -31,7 +31,6 @@ class SupervisorAppInfoDashboard extends LitElement {
<supervisor-app-info
.narrow=${this.narrow}
.route=${this.route}
.hass=${this.hass}
.addon=${this.addon}
.controlEnabled=${this.controlEnabled}
></supervisor-app-info>
@@ -92,14 +92,15 @@ import {
showConfirmationDialog,
} from "../../../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../../../dialogs/more-info/show-ha-more-info-dialog";
import { MobileAwareMixin } from "../../../../../mixins/mobile-aware-mixin";
import { mdiHomeAssistant } from "../../../../../resources/home-assistant-logo-svg";
import { haStyle } from "../../../../../resources/styles";
import type { Route } from "../../../../../types";
import { bytesToString } from "../../../../../util/bytes-to-string";
import { getAppDisplayName } from "../../common/app";
import "../components/supervisor-app-metric";
import "../../components/supervisor-apps-tag";
import "../../components/supervisor-apps-state";
import "../../components/supervisor-apps-tag";
import "../components/supervisor-app-metric";
import { extractChangelog } from "../util/supervisor-app";
import "./supervisor-app-system-managed";
@@ -123,7 +124,7 @@ const RATING_ICON = {
const POLL_INTERVAL_SECONDS = 5;
@customElement("supervisor-app-info")
class SupervisorAppInfo extends LitElement {
class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@@ -163,6 +164,9 @@ class SupervisorAppInfo extends LitElement {
private _pollInterval?: number;
protected mobileSizeQuery =
"all and (max-width: 1120px), all and (max-height: 500px)";
private get _currentAddon(): HassioAddonDetails | StoreAddonDetails {
return this._addon || this.addon;
}
@@ -863,11 +867,11 @@ class SupervisorAppInfo extends LitElement {
`
: nothing}
<div
class="app ${this.narrow || !this._currentAddon.version
class="app ${this._isMobileSize || !this._currentAddon.version
? "column"
: ""}"
>
${this.narrow || !this._currentAddon.version
${this._isMobileSize || !this._currentAddon.version
? html`
${this._renderInfoCard()}
${this._currentAddon.version
@@ -0,0 +1,225 @@
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import {
mdiPalette,
mdiPlayCircleOutline,
mdiPlaylistCheck,
mdiRobotOutline,
mdiScriptTextOutline,
} from "@mdi/js";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-adaptive-dialog";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import {
areasContext,
internationalizationContext,
} from "../../../data/context";
import type { SceneEntities } from "../../../data/scene";
import { showSceneEditor } from "../../../data/scene";
import {
addToActionHandler,
type AddToActionKey,
} from "../../../dialogs/more-info/add-to";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { AreaAddToDialogParams } from "./show-dialog-area-add-to";
@customElement("dialog-area-add-to")
class DialogAreaAddTo extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@state() private _params?: AreaAddToDialogParams;
@state() private _open = false;
public showDialog(params: AreaAddToDialogParams): void {
this._params = params;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
@closed=${this._dialogClosed}
>
${this._renderOptions()}
</ha-adaptive-dialog>
`;
}
private _renderOptions() {
if (!this._params) {
return nothing;
}
const area = this._areas[this._params.areaId];
const areaName = computeAreaName(area) || this._params.areaId;
return html`
<h3 class="section-header">
${this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list>
${this._renderActionItem(
"automation_trigger",
mdiRobotOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_trigger",
areaName
)}
${this._renderActionItem(
"automation_condition",
mdiPlaylistCheck,
"ui.dialogs.more_info_control.add_to.actions.automation_condition",
areaName
)}
${this._renderActionItem(
"automation_action",
mdiPlayCircleOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_action",
areaName
)}
</ha-list>
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.script.scripts_heading")}
</h3>
<ha-list>
${this._renderActionItem(
"script_action",
mdiScriptTextOutline,
"ui.dialogs.more_info_control.add_to.actions.script_action",
areaName
)}
</ha-list>
${this._renderSceneSection(areaName)}
`;
}
private _renderSceneSection(areaName: string) {
if (!this._params?.entityIds.length) {
return nothing;
}
return html`
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.scene.scenes_heading")}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
@click=${this._handleCreateScene}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiPalette}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.scene",
{ target: areaName }
)}
</ha-list-item>
</ha-list>
`;
}
private _renderActionItem(
key: AddToActionKey,
path: string,
translationKey:
| "ui.dialogs.more_info_control.add_to.actions.automation_trigger"
| "ui.dialogs.more_info_control.add_to.actions.automation_condition"
| "ui.dialogs.more_info_control.add_to.actions.automation_action"
| "ui.dialogs.more_info_control.add_to.actions.script_action",
areaName: string
) {
return html`
<ha-list-item
graphic="icon"
data-type=${key}
@click=${this._handleAction}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${path}></ha-svg-icon>
${this._i18n.localize(translationKey, { target: areaName })}
</ha-list-item>
`;
}
private _handleAction(ev: Event) {
if (!this._params) {
return;
}
const key = (ev.currentTarget as HTMLElement).dataset
.type as AddToActionKey;
this.closeDialog();
addToActionHandler(key, { area_id: this._params.areaId });
}
private _handleCreateScene() {
if (!this._params) {
return;
}
const entities: SceneEntities = {};
for (const entityId of this._params.entityIds) {
entities[entityId] = "";
}
this.closeDialog();
showSceneEditor({ entities }, this._params.areaId);
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-4) 0;
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-area-add-to": DialogAreaAddTo;
}
}
+229 -23
View File
@@ -1,6 +1,21 @@
import { consume } from "@lit/context";
import { mdiDelete, mdiDotsVertical, mdiImagePlus, mdiPencil } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import {
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiImagePlus,
mdiPalette,
mdiPencil,
mdiPlus,
mdiRobot,
mdiScriptText,
mdiShape,
mdiTools,
} from "@mdi/js";
import type {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -10,7 +25,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack } from "../../../common/navigate";
import { goBack, navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
@@ -18,11 +33,13 @@ import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import {
@@ -38,6 +55,7 @@ import {
computeEntityRegistryName,
sortEntityRegistryByName,
} from "../../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
@@ -46,9 +64,15 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import "../../logbook/ha-logbook";
import {
loadAreaAddToDialog,
showAreaAddToDialog,
} from "./show-dialog-area-add-to";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -59,8 +83,60 @@ declare interface NameAndEntity<EntityType extends HassEntity> {
entity: EntityType;
}
type AreaQuickLinkKey =
| "devices"
| "entities"
| "helpers"
| "automations"
| "scenes"
| "scripts";
const NAVIGATION_ACTIONS: {
value: string;
path: string;
icon: string;
countKey: AreaQuickLinkKey;
}[] = [
{
value: "navigate-devices",
path: "/config/devices/dashboard",
icon: mdiDevices,
countKey: "devices",
},
{
value: "navigate-entities",
path: "/config/entities",
icon: mdiShape,
countKey: "entities",
},
{
value: "navigate-helpers",
path: "/config/helpers",
icon: mdiTools,
countKey: "helpers",
},
{
value: "navigate-automations",
path: "/config/automation/dashboard",
icon: mdiRobot,
countKey: "automations",
},
{
value: "navigate-scenes",
path: "/config/scene/dashboard",
icon: mdiPalette,
countKey: "scenes",
},
{
value: "navigate-scripts",
path: "/config/script/dashboard",
icon: mdiScriptText,
countKey: "scripts",
},
] as const;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends LitElement {
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public areaId!: string;
@@ -69,14 +145,14 @@ class HaConfigAreaPage extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@state() private _related?: RelatedResult;
@state() private _newTriggersConditions = false;
private _logbookTime = { recent: 86400 };
private _memberships = memoizeOne(
@@ -128,9 +204,35 @@ class HaConfigAreaPage extends LitElement {
.concat(memberships.indirectEntities.map((entry) => entry.entity_id))
);
private _getQuickLinkCounts = memoizeOne(
(
memberships: {
devices: DeviceRegistryEntry[];
entities: EntityRegistryEntry[];
indirectEntities: EntityRegistryEntry[];
},
related?: RelatedResult
) => {
const allEntityIds = this._allEntities(memberships);
const entityIds = related?.entity ?? allEntityIds;
return {
devices: related?.device?.length ?? memberships.devices.length,
entities: entityIds.length,
helpers: entityIds.filter((entityId) =>
isHelperDomain(computeDomain(entityId))
).length,
automations: related?.automation?.length ?? 0,
scenes: related?.scene?.length ?? 0,
scripts: related?.script?.length ?? 0,
};
}
);
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
loadAreaAddToDialog();
}
protected updated(changedProps: PropertyValues<this>) {
@@ -140,6 +242,23 @@ class HaConfigAreaPage extends LitElement {
}
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
if (!isComponentLoaded(this.hass!.config, "automation")) {
return [];
}
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
),
];
}
protected render() {
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
return nothing;
@@ -162,6 +281,10 @@ class HaConfigAreaPage extends LitElement {
this._entityReg
);
const { devices, entities } = memberships;
const quickLinkCounts = this._getQuickLinkCounts(
memberships,
this._related
);
// Pre-compute the entity and device names, so we can sort by them
if (devices) {
@@ -245,6 +368,21 @@ class HaConfigAreaPage extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${NAVIGATION_ACTIONS.map(
(action) => html`
<ha-dropdown-item value=${action.value}>
<ha-svg-icon slot="icon" .path=${action.icon}></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.areas.quick_links.${action.countKey}`,
{ count: quickLinkCounts[action.countKey] }
)}
<ha-icon-next slot="details"></ha-icon-next>
</ha-dropdown-item>
`
)}
<wa-divider></wa-divider>
<ha-dropdown-item value="edit" .data=${area}>
<ha-svg-icon slot="icon" .path=${mdiPencil}> </ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.edit_settings")}
@@ -271,15 +409,41 @@ class HaConfigAreaPage extends LitElement {
class="img-edit-btn"
></ha-icon-button>
</div>`
: html`<ha-button
appearance="filled"
size="small"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon
.path=${mdiImagePlus}
slot="start"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.areas.add_picture"
)}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon
slot="start"
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
@@ -612,9 +776,39 @@ class HaConfigAreaPage extends LitElement {
this._related = await findRelated(this.hass, "area", this.areaId);
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
private _showAddToDialog() {
const area = this.hass.areas[this.areaId];
if (!area) {
return;
}
showAreaAddToDialog(this, {
areaId: area.area_id,
entityIds: this._areaEntityIds,
});
}
private get _areaEntityIds(): string[] {
const memberships = this._memberships(
this.areaId,
Object.values(this.hass.devices),
this._entityReg
);
return this._allEntities(memberships);
}
private _handleMenuAction(
ev: HaDropdownSelectEvent<string, AreaRegistryEntry>
) {
const action = ev.detail?.item?.value;
const entry = (ev.detail?.item as any)?.data as AreaRegistryEntry;
const entry = ev.detail?.item?.data;
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
return;
}
switch (action) {
case "edit":
this._openDialog(entry);
@@ -625,15 +819,19 @@ class HaConfigAreaPage extends LitElement {
}
}
private _showSettings(ev: MouseEvent) {
const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry;
this._openDialog(entry);
private _showSettings(
ev: HASSDomCurrentTargetEvent<
HTMLButtonElement & { entry: AreaRegistryEntry }
>
) {
this._openDialog(ev.currentTarget.entry);
}
private _openEntity(ev) {
const entry: EntityRegistryEntry = (ev.currentTarget as any).entity;
private _openEntity(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entity: EntityRegistryEntry }>
) {
showMoreInfoDialog(this, {
entityId: entry.entity_id,
entityId: ev.currentTarget.entity.entity_id,
});
}
@@ -675,6 +873,14 @@ class HaConfigAreaPage extends LitElement {
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.action-buttons {
display: flex;
gap: var(--ha-space-2);
flex-wrap: wrap;
justify-content: space-around;
}
img {
border-radius: var(
--ha-card-border-radius,
@@ -13,8 +13,6 @@ class HaConfigAreas extends HassRouterPage {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
@@ -37,7 +35,6 @@ class HaConfigAreas extends HassRouterPage {
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced;
pageEl.route = this.routeTail;
}
}
@@ -0,0 +1,19 @@
import { fireEvent } from "../../../common/dom/fire_event";
export interface AreaAddToDialogParams {
areaId: string;
entityIds: string[];
}
export const loadAreaAddToDialog = () => import("./dialog-area-add-to");
export const showAreaAddToDialog = (
element: HTMLElement,
params: AreaAddToDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-area-add-to",
dialogImport: loadAreaAddToDialog,
dialogParams: params,
});
};
@@ -107,7 +107,7 @@ export default class HaAutomationActionEditor extends LitElement {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...(this.action.comment ? { comment: this.action.comment } : {}),
...(this.action.note ? { note: this.action.note } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
@@ -297,8 +297,8 @@ export default class HaAutomationActionRow extends LitElement {
?.target
: undefined;
const commentTooltipText = truncateWithEllipsis(
this.action.comment?.trim() || "",
const noteTooltipText = truncateWithEllipsis(
this.action.note?.trim() || "",
250
);
@@ -337,18 +337,19 @@ export default class HaAutomationActionRow extends LitElement {
serviceTargetSpec
)
: nothing}
${commentTooltipText
${noteTooltipText
? html`
<ha-svg-icon
id="comment-icon"
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
)}
class="comment-indicator"
class="note-indicator"
></ha-svg-icon
><ha-tooltip for="comment-icon"
><p>${commentTooltipText}</p></ha-tooltip
><ha-tooltip for="note-icon"
><p>${noteTooltipText}</p></ha-tooltip
>
`
: nothing}
@@ -407,11 +408,11 @@ export default class HaAutomationActionRow extends LitElement {
)
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_comment">
<ha-dropdown-item value="edit_note">
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.action.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.action.note ? "edit" : "add"}`
)
)}
</ha-dropdown-item>
@@ -941,25 +942,25 @@ export default class HaAutomationActionRow extends LitElement {
}
};
private _editCommentAction = async (): Promise<void> => {
const comment = await showPromptDialog(this, {
private _editNoteAction = async (): Promise<void> => {
const note = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.action.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.action.note ? "edit" : "add"}`
),
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
),
inputType: "string",
defaultValue: this.action.comment,
defaultValue: this.action.note,
confirmText: this.hass.localize("ui.common.submit"),
multiline: true,
});
if (comment !== null) {
if (note !== null) {
const value = { ...this.action };
if (comment === "") {
delete value.comment;
if (note === "") {
delete value.note;
} else {
value.comment = comment;
value.note = note;
}
fireEvent(this, "value-changed", {
value,
@@ -1089,7 +1090,7 @@ export default class HaAutomationActionRow extends LitElement {
rename: () => {
this._renameAction();
},
editComment: this._editCommentAction,
editNote: this._editNoteAction,
toggleYamlMode: () => {
this._toggleYamlMode();
this.openSidebar();
@@ -1185,8 +1186,8 @@ export default class HaAutomationActionRow extends LitElement {
case "rename":
this._renameAction();
break;
case "edit_comment":
this._editCommentAction();
case "edit_note":
this._editNoteAction();
break;
case "duplicate":
this._duplicateAction();
@@ -143,6 +143,7 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
const addActionTargetFromQuery = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
"action"
);
@@ -130,6 +130,7 @@ import "./add-automation-element/ha-automation-add-items";
import "./add-automation-element/ha-automation-add-search";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
@@ -311,6 +312,7 @@ class DialogAddAutomationElement
const queryTarget = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
params.type
);
this._openedFromQuery = !!queryTarget;
@@ -320,6 +322,7 @@ class DialogAddAutomationElement
searchParams.delete(ADD_AUTOMATION_ELEMENT_QUERY_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM);
mainWindow.history.replaceState(
mainWindow.history.state,
"",
@@ -95,7 +95,6 @@ class DialogAutomationMode extends LitElement implements HassDialog {
.value=${this._newMode}
@value-changed=${this._modeChanged}
.maxColumns=${1}
.hass=${this.hass}
></ha-select-box>
${isMaxMode(this._newMode)
@@ -123,7 +123,7 @@ export default class HaAutomationConditionEditor extends LitElement {
ev.stopPropagation();
const value = {
...(this.condition.alias ? { alias: this.condition.alias } : {}),
...(this.condition.comment ? { comment: this.condition.comment } : {}),
...(this.condition.note ? { note: this.condition.note } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
@@ -44,6 +44,7 @@ import type { HaAutomationRow } from "../../../../components/automation/ha-autom
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/automation/ha-automation-row-live-test";
import type { LiveTestState } from "../../../../components/automation/ha-automation-row-live-test";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-condition-icon";
import "../../../../components/ha-dropdown";
@@ -153,7 +154,10 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _selected = false;
@state() private _liveTestResult: LiveTestState = "unknown";
@state() private _liveTestResult: {
state: LiveTestState;
message?: string;
} = { state: "unknown" };
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
@@ -201,8 +205,8 @@ export default class HaAutomationConditionRow extends LitElement {
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
const commentTooltipText = truncateWithEllipsis(
this.condition.comment?.trim() || "",
const noteTooltipText = truncateWithEllipsis(
this.condition.note?.trim() || "",
250
);
@@ -223,19 +227,18 @@ export default class HaAutomationConditionRow extends LitElement {
conditionTargetSpec
)
: nothing}
${this.condition.comment?.trim()
${this.condition.note?.trim()
? html`
<ha-svg-icon
id="comment-icon"
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
)}
class="comment-indicator"
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="comment-icon"
><p>${commentTooltipText}</p></ha-tooltip
>
<ha-tooltip for="note-icon"><p>${noteTooltipText}</p></ha-tooltip>
`
: nothing}
</h3>
@@ -285,11 +288,11 @@ export default class HaAutomationConditionRow extends LitElement {
)
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_comment">
<ha-dropdown-item value="edit_note">
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.condition.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.condition.note ? "edit" : "add"}`
)
)}
</ha-dropdown-item>
@@ -529,10 +532,13 @@ export default class HaAutomationConditionRow extends LitElement {
>${this._renderRow()}
<ha-automation-row-live-test
slot="icons"
.state=${this._liveTestResult}
.state=${this.condition.condition !== "trigger"
? this._liveTestResult.state
: "unknown"}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult}`
`ui.panel.config.automation.editor.conditions.live_test_state.${this.condition.condition !== "trigger" ? this._liveTestResult.state : "unknown"}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test
></ha-automation-row>`
: html`
@@ -619,7 +625,12 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _resetSubscription() {
this._liveTestResult = "unknown";
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
if (this._conditionUnsub) {
this._conditionUnsub.then((unsub) => unsub());
this._conditionUnsub = undefined;
@@ -644,7 +655,12 @@ export default class HaAutomationConditionRow extends LitElement {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = result.result ? "pass" : "fail";
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
}
},
this.condition
@@ -661,7 +677,12 @@ export default class HaAutomationConditionRow extends LitElement {
private _handleLiveTestError(error: any) {
const invalid =
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = invalid ? "invalid" : "unknown";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
}
private _onValueChange(event: CustomEvent) {
@@ -828,25 +849,25 @@ export default class HaAutomationConditionRow extends LitElement {
}
};
private _editCommentCondition = async (): Promise<void> => {
const comment = await showPromptDialog(this, {
private _editNoteCondition = async (): Promise<void> => {
const note = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.condition.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.condition.note ? "edit" : "add"}`
),
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
),
inputType: "string",
defaultValue: this.condition.comment,
defaultValue: this.condition.note,
confirmText: this.hass.localize("ui.common.submit"),
multiline: true,
});
if (comment !== null) {
if (note !== null) {
const value = { ...this.condition };
if (comment === "") {
delete value.comment;
if (note === "") {
delete value.note;
} else {
value.comment = comment;
value.note = note;
}
fireEvent(this, "value-changed", {
value,
@@ -1001,7 +1022,7 @@ export default class HaAutomationConditionRow extends LitElement {
rename: () => {
this._renameCondition();
},
editComment: this._editCommentCondition,
editNote: this._editNoteCondition,
toggleYamlMode: () => {
this._toggleYamlMode();
this.openSidebar();
@@ -1073,8 +1094,8 @@ export default class HaAutomationConditionRow extends LitElement {
case "rename":
this._renameCondition();
break;
case "edit_comment":
this._editCommentCondition();
case "edit_note":
this._editNoteCondition();
break;
case "duplicate":
this._duplicateCondition();
@@ -128,6 +128,7 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
const addConditionTargetFromQuery = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
"condition"
);
@@ -22,7 +22,7 @@ import type { HomeAssistant } from "../../../../../types";
const numericStateConditionStruct = object({
alias: optional(string()),
comment: optional(string()),
note: optional(string()),
condition: literal("numeric_state"),
entity_id: optional(string()),
attribute: optional(string()),
@@ -25,7 +25,7 @@ import type { ConditionElement } from "../ha-automation-condition-row";
const stateConditionStruct = object({
alias: optional(string()),
comment: optional(string()),
note: optional(string()),
condition: literal("state"),
entity_id: optional(string()),
attribute: optional(string()),
@@ -270,7 +270,7 @@ class DialogNewAutomation extends LitElement {
: nothing}
${processedBlueprints.length > 0
? html`
<ha-tip .hass=${this.hass}>
<ha-tip>
<a
href=${documentationUrl(
this.hass,

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