Compare commits

...
Author SHA1 Message Date
Paul BotteinandClaude Opus 4.6 c93661eaf7 Clean up retro theme and fix color scale issues
- Add missing ha-font-family-longform override
- Fix light neutral scale contrast (text-disabled, on-disabled tokens)
- Fix dark neutral scale to be monotonically ascending
- Adjust dark primary-90/95 for visible selection fills
- Move lovelace teal to lovelace-background, use gray for pages
- Remove redundant derived variables (mdc-*, switch-*, table-*, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-31 13:18:25 +02:00
Paul Bottein 7dac2e243f Improve theme 2026-03-31 13:18:25 +02:00
Paul BotteinandClaude Opus 4.6 f9e1023a2e Rename windows-98 feature to retro to avoid trademark issues
Renames all component files, class names, element tags, translation
keys, theme names, and storage keys from windows-98/Windows 98 to
retro/Retro.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-31 13:18:25 +02:00
Paul Bottein 82a0d9a413 Add translations 2026-03-31 13:18:25 +02:00
Paul Bottein 1325acdba3 Add BSOD 2026-03-31 13:18:25 +02:00
Paul BotteinandClaude Opus 4.6 07878563be Add labs feature toggle, translations, and random tips
- Subscribe to frontend.windows_98 lab feature to enable/disable
- Move tips to lazy-loaded translation fragment (ui.panel.windows_98)
- Randomize tip selection
- Sort windows_98 with other fun features in labs page

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-31 13:18:25 +02:00
Paul BotteinandClaude Opus 4.6 b0d5aa5e27 Add Clippy-style home automation tip
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-31 13:18:25 +02:00
Paul BotteinandClaude Opus 4.6 444f98df66 Remove outdated right-click tip
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-31 13:18:25 +02:00
Paul BotteinandClaude Opus 4.6 f5e62a2c7d Add Windows 98 Easter egg with Casita assistant
Adds a Clippy-inspired interactive house character (Casita) that applies
a full Windows 98 theme. Features draggable positioning, speech bubble
with fun tips, idle/sleep animations, and a dismiss button. Theme is
enforced via MutationObserver to survive theme mixin overwrites.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-31 13:18:25 +02:00
Norbert RittelandGitHub 32b9676f97 Change picker descriptions of triggers to match new style (#51294) 2026-03-31 10:06:47 +02:00
Petar PetrovandGitHub 7876642f35 Fix x-axis labels for statistics graph month/year periods (#51295) 2026-03-31 10:01:52 +02:00
Paul BotteinandGitHub 0e3bcfad5e Hide section when all cards are hidden (#51281) 2026-03-31 08:38:09 +02:00
cd1c273d5a Add people at home summary tile to home overview dashboard (#30408)
* Add persons summary tile to home overview dashboard

Show how many people are currently home in the Summary section
of the default home dashboard. Only persons with at least one
tracking device are included. The tile only appears when the map
panel is loaded and at least one tracked person entity exists.
Tapping navigates to the map panel. Displays a count of persons
home or "Nobody" when all are away.

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

* Remove persons tile from home overview strategy

* Translation tweak

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-03-31 07:56:44 +03:00
d92ac4b4b7 Add solo-select gesture to chart legend (#30395)
* Add solo-select gesture to chart legend

Ctrl+click (Cmd+click on Mac) or long-press (touch, 500ms) a legend
item to solo-select it:
- Solo-click any item → hide everything else, show only that item
- Solo-click the only visible item → restore all

There is no special "solo mode" — the gesture simply sets which items
are hidden. Normal click/tap continues to toggle individual series,
including after a solo action (e.g. solo a, then click b to add it).

Closes https://github.com/orgs/home-assistant/discussions/1492

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

* Deduplicate legend parsing in _renderLegend and _getAllLegendIds

Both methods parsed options.legend and filtered datasets identically.
Extract the shared logic into a new _getLegendItems method.

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

* Update src/components/chart/ha-chart-base.ts

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-03-30 15:08:16 +00:00
Petar PetrovandGitHub bfecb1d4a9 Disable physics by default for large networks (#51277) 2026-03-30 14:22:11 +00:00
WendelinandGitHub 69a8db00fa Fix ha-dropdown z-index for legacy browsers (#51276) 2026-03-30 13:41:44 +00:00
Maarten LakerveldandGitHub bbda7affdc Add ability to duplicate a section (#30265)
* Add ability to duplicate a section

* Move section edit mode buttons to overflow menu

* Fix typing for concat and push parameters

* Fix incorrect clipboard typing for badges
2026-03-30 14:59:27 +02:00
Aidan TimsonandGitHub 10c90d222d Limit ha-toast width to window, refactor CSS (#51272)
* Limit `ha-toast` width to window and use safe width

* Query assigned slots to stop actions display

* Constrain max-width

* Increase start/end padding
2026-03-30 15:31:03 +03:00
Bram KragtenandGitHub 072f70b49f Numeric threshold selector: remove duplicate uom from input (#51275) 2026-03-30 14:12:04 +02:00
WendelinandGitHub 7f2a5ecc27 Remove mobile-specific styles for date-range-picker (#51273)
Remove mobile-specific styles for date-picker component
2026-03-30 13:03:21 +02:00
Paul BotteinandGitHub a42f6f864a Reduce heading button badge font size and fix alignement (#51274)
Title: Reduce heading button badge font size and fix alignement
2026-03-30 13:02:06 +02:00
Paulus SchoutsenandGitHub a07772c514 Reload the app info after an update completes (#51261) 2026-03-30 10:42:16 +01:00
Tom CarpenterandGitHub a6ab6e218f Fix new date-range-picker rendering on small screens (#51257) 2026-03-30 11:10:06 +02:00
ed96657085 Add getContrastedColorHex() to be used for contrasted text & background (or vice versa) (#29032)
Co-authored-by: Wendelin <[email protected]>
2026-03-30 10:57:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
50ca39722e Bump github/codeql-action from 4.34.1 to 4.35.1 (#51268)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 08:43:59 +01:00
WendelinandGitHub 7026e5b375 Fix date-range-picker preset selection (#51269) 2026-03-30 09:40:40 +02:00
WendelinandGitHub 37e8e1b728 Fix time input background (#51270)
Fix input color tokens
2026-03-30 09:39:57 +02:00
Paulus SchoutsenandGitHub 48369854af link to supervisor logs on app install error (#51259) 2026-03-30 09:00:21 +02:00
Tom CarpenterandGitHub 7715e01126 Add date range picker time validation (#51267)
* Fix base time inputs reportValidity() function

The queryAll selector returns a NodeList not not an array. Need to spread it to an array before we can use every().

* Validate the date range picker time inputs

Enable auto validation to get the nice red underline on invalid values, and then check validity before accepting the input.

* Fix automatic 24hr value conversion in AM/PM format

When using AM/PM, entering a 24 hour value will automatically convert the first time. For example 15 will become 3. However if you then enter 15 again it will stay as 15 and not update.
To fix this, make sure we trigger an update of the input field once the current update cycle is complete.

* Validate time inputs on save not value update

In the value changed callback, the update 24->12hr input correction will not have been updated and therefore they will report invalid.
2026-03-30 09:13:51 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e4ee108e14 Update vitest monorepo to v4.1.2 (#51265)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 17:25:23 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
407609c118 Update dependency @swc/helpers to v0.5.20 (#51264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 17:25:18 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
68cbaf6481 Bump node-forge from 1.3.3 to 1.4.0 (#30398)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-28 13:26:07 +01:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b9b249a317 Update dependency @codemirror/language to v6.12.3 (#30396)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 12:04:55 +01:00
Petar PetrovandGitHub df6578dfdf Refactor climate HVAC mode handling to be data-driven (#30356)
Refactor climate HVAC mode handling in state-history-chart-line to be data-driven

Replace repetitive per-mode detection, dataset creation, and series population
blocks with a single CLIMATE_MODE_CONFIGS array and loops over active modes.
2026-03-28 10:15:45 +01:00
ildar170975andGitHub b4a07f504c Map card editor: use context in attribute selector (#30393)
use context in attribute selector
2026-03-28 10:13:44 +01:00
Norbert RittelandGitHub 24b8078a9c Clarify multiple choice of Chart types in Device energy graph card (#30389) 2026-03-28 09:31:19 +02:00
Paul BotteinandGitHub 2bd8a657b8 Revert entity naming change (#30384) 2026-03-27 21:45:54 +01:00
WendelinandGitHub 61724a52ba Fix picker field disabled background (#30385) 2026-03-27 16:32:49 +01:00
Aidan TimsonandGitHub 67d73261a4 Fix floating ha-toast (#30344) 2026-03-27 14:06:47 +01:00
419ba64bed Use dedicated tab copy in automation add dialogs (#30378)
Co-authored-by: Wendelin <[email protected]>
2026-03-27 12:46:11 +00:00
Norbert RittelandGitHub e87155bba3 Drop "entities" from "Group by floor / area" in Sankey diagrams (#30382) 2026-03-27 14:16:46 +02:00
Petar PetrovandGitHub d8a3939e22 Fix negative currency display on sensor card (#30359) 2026-03-27 12:29:58 +01:00
Paul BotteinandGitHub 5141b6aea9 Revert "Show device name prefix in entity name field" (#30381)
Revert "Show device name prefix in entity name field (#30358)"

This reverts commit e1ac7e98b0.
2026-03-27 12:20:00 +01:00
WendelinandGitHub 9aaf1bdd07 Automation add TCA: Fix classMap usage (#30380) 2026-03-27 12:14:51 +01:00
a0e7c8e9bb Use goBack with fallback for app uninstall navigation (#30377)
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-27 08:31:19 +00:00
Bram KragtenandGitHub 8f482776b9 Numeric threshold selector fixes (#30350)
* Update numeric threshold

* Update ha-selector-numeric-threshold.ts
2026-03-27 09:50:21 +02:00
Norbert RittelandGitHub 2c80183ded Shorten collection_key_description to fit available space (#30376) 2026-03-27 09:41:58 +02:00
Paul BotteinandGitHub 9bd5e25ee3 Only display entity name instead of friendly name in state info (#30365) 2026-03-27 09:41:11 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
994193465f Bump picomatch from 2.3.1 to 2.3.2 (#30375)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:37:15 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
530b7ed457 Update dependency @rspack/core to v1.7.10 (#30374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:17:55 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
24332e8288 Update vitest monorepo to v4.1.1 (#30357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:17:23 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
980aaa2bac Update dependency typescript-eslint to v8.57.2 (#30367)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:16:47 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
344856cbc4 Update dependency tar to v7.5.13 (#30371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:16:19 +02:00
Bram KragtenandGitHub 06877cbaaa Replace softprops/action-gh-release with gh release upload (#30362)
`replace softprops/action-gh-release` with `gh release upload`
2026-03-26 17:39:38 +01:00
Paul BotteinandGitHub e1ac7e98b0 Show device name prefix in entity name field (#30358) 2026-03-26 17:13:24 +01:00
Paul BotteinandGitHub 4b2426ab77 Add composed/text mode toggle to entity name picker (#30337) 2026-03-26 17:05:05 +01:00
Paul BotteinandGitHub f9471d6b4c Replace computeLovelaceEntityName with hass.formatEntityName (#30351) 2026-03-26 17:01:26 +01:00
Bram KragtenandGitHub 0645484258 Tighten action permissions (#30361) 2026-03-26 16:45:34 +01:00
Bram KragtenandGitHub 2da3efb812 Add target error badge if target is missing (#30352)
* Add target error badge if target is missing

* Don't show for newly added items
2026-03-26 16:58:22 +02:00
Bram KragtenandGitHub e3674e550f Replace ua-parser-js with simple regexs (#30355) 2026-03-26 14:48:05 +00:00
karwostsandGitHub cff038fea4 Stabilize map colors (#30354) 2026-03-26 14:47:57 +00:00
Paulus SchoutsenandGitHub bed39fd8e9 Hide features layout option until feature added (#30353) 2026-03-26 14:39:00 +00:00
arcsurandGitHub b8346d0286 Support additional climate hvac_mode in state-history-chart-line (#30310)
* Support additional climate hvac_mode in state-history-chart-line

* Reverted changes to the chart legend to keep change focussed on the additonal hvac_mode support

* Remove the tests since they don't add value.
2026-03-26 14:36:08 +00:00
Petar PetrovandGitHub 5f80b74322 Fix energy pie chart legend showing raw data instead of formatted values (#30339) 2026-03-26 16:29:16 +02:00
Paul BotteinandGitHub 73e0f1e18d Use hardcoded label for temperature and humidity sensor in climate dashboard (#30348)
* Only use entity name for climate view sensors

* Use hardcoded text
2026-03-26 15:06:25 +01:00
Paul BotteinandGitHub 0b16b735e8 Fix stack card scrollbar clipping box-shadows (#30346)
* Fix stack card scrollbar clipping box-shadows

* Remove grid options

* Remove scrollbar
2026-03-26 13:16:57 +01:00
Darren GriffinandGitHub c88dcf10b9 Update Figma DesignKit link (#30342) 2026-03-26 09:47:31 +00:00
TimothyandGitHub 08bc23e2e7 Set tap highlight color to transparent for button (#30340) 2026-03-26 08:55:27 +00:00
pefiaandGitHub 97d49f06b1 Fix number formatting fallback when Number.isNaN is unavailable (#30306)
Remove unnecessary Number.isNaN polyfill from number formatting
2026-03-26 10:33:29 +02:00
50b727393d Add all hardware table (#30312)
* add all hardware table

* copilot review

* Updated tab names

* update localize keys

* Fix translations

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-03-26 09:10:09 +02:00
sir-UnknownandGitHub 5c6dd2a697 Fix calendar event description not preserving line breaks (#30329)
Add `white-space: pre-line` to the event description style so that
newlines in the calendar event description are rendered correctly
instead of being collapsed into a single line.
2026-03-26 07:01:02 +01:00
Bram Kragten 87758cc228 Merge branch 'rc' into dev 2026-03-25 16:52:28 +01:00
Bram Kragten 60e8b8b505 Bumped version to 20260325.0 2026-03-25 16:51:43 +01:00
Aidan TimsonandGitHub 3c012c30ac Migrate ha-toast to wa-popup instead of wa-popover (#30327) 2026-03-25 16:51:09 +01:00
Paul BotteinandGitHub 84d234a330 Add support for climate swing horizontal mode in get_states (#30326) 2026-03-25 16:04:49 +01:00
a12543fe74 Add ha-input-docs (#30315)
Co-authored-by: Petar Petrov <[email protected]>
2026-03-25 15:46:28 +01:00
karwostsandGitHub cc53f977a2 Fix trend and sensor graph when no history (#30323) 2026-03-25 15:37:28 +01:00
Paul BotteinandGitHub 71541625d7 Add support for infrared domain (#30321) 2026-03-25 13:43:50 +00:00
Norbert RittelandGitHub 43da700ccc Clarify "wait_for_triggers" summary to reflect the OR condition (#30320) 2026-03-25 13:41:23 +00:00
WendelinandGitHub efbbdbf3e8 Replace search-input-outlined with ha-input-search (#30319) 2026-03-25 14:33:55 +01:00
Bram KragtenandGitHub eee6f79639 Add mode option to numeric threshold selector (#30311) 2026-03-25 14:04:21 +01:00
WendelinandGitHub 9381bbd656 ha-input outlined appearance (#30231)
* refactor: replace search-input-outlined with ha-input-search component across multiple files

* review

* Migrate search-input

* refactor: remove deprecated search-input component

* More outlined search fields

* Review
2026-03-25 11:01:14 +01:00
pefiaandGitHub 2724087290 Fix cast manager listener unsubscribe behavior (#30307) 2026-03-25 10:49:09 +01:00
WendelinandGitHub 6bdf1ccd8c Migrate ha-textfields to ha-input in 24 files (#30298)
* migrate ha-textfields to ha-input in 24 files

* Fix import path for ha-input and update attribute syntax in entity-preview-row
2026-03-25 10:28:10 +01:00
79743c0afa Add search to network visualization graphs (#29908)
* Add search highlight to ZHA graph

* Move logic upstream and extend search to zwave and bluetooth

* Move search down to avoid collisions with graph legend

* Fix mobile; simplify code

* Apply highlights directly on search callback

* Revert "Move search down to avoid collisions with graph legend"

This reverts commit 4578aec9c3.

* Move legend down

* Make search bar shrink to avoid overlapping buttons

* Move search bar to topbar on mobile

* Fix inset

* Fix small controlls position

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-03-25 09:16:26 +00:00
Paul BotteinandGitHub 15b1df5a58 Add light toggle button to home area view (#30301) 2026-03-25 09:53:20 +01:00
Paul BotteinandGitHub 8222d9796c Show related entities warning when deleting helpers (#30302) 2026-03-25 09:52:19 +01:00
WendelinandGitHub 3337b414d7 Fix copy button in dev tools (#30313)
Fix copy dev tools entity id
2026-03-25 09:48:58 +01:00
Alex GustafssonandGitHub ce90d83c92 ZHA group settings UI improvements, localization (#30251)
* Add device area column to ZHA data table

Add a device area column to the ZHA data table, which most notably adds
the column to the "create [ZigBee] group" view".

The column is only show on wider screens to allow for ordering, whilst
narrower screens will show the area as a subtitle to the device's name.

* Localize the ZHA group data table

* fixup! Add device area column to ZHA data table

* fixup! Localize the ZHA group data table
2026-03-25 09:03:45 +02:00
1f6d0d2e63 Add a period option to todo-list-card (#30151)
* Added a todo-list card option "days_to_show" to filter tasks far in the future (#24020)

* Adjusted min and 0 values as suggested in PR

* Adjusted as suggested in review

* Switched from days_to_show to period (calendar only for now) as suggested

* removed days_to_show from editor UI

* fixed lint error

* Fixed code style with prettier

* fix filtering

* Update filtering period options

* Update src/panels/lovelace/editor/config-elements/hui-todo-list-editor.ts

Co-authored-by: Petar Petrov <[email protected]>

* Apply suggestion from @MindFreeze

Co-authored-by: Petar Petrov <[email protected]>

* prettier/lint

* fix memoization, items without status

* no rolling window

* refresh on date change

* Show dialog on create when using due_date filter

---------

Co-authored-by: cpetry <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-03-25 08:45:10 +02:00
karwostsandGitHub 0327b02d0b Fix clearing device class in entity registry (#30303) 2026-03-25 08:21:48 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
780db9b066 Update Node.js to v24.14.1 (#30309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 08:19:06 +02:00
Bram Kragten a2a38e1da7 Bumped version to 20260312.1 2026-03-23 12:40:43 +01:00
Petar PetrovandBram Kragten 88c063ba2a Fix hasReturnToGrid only checking first grid source in energy distribution card (#30273) 2026-03-23 12:39:36 +01:00
eb8b2a9d17 Skip plotting state value on statistic graph if units mismatch (#30214)
* Use isExternalStatistic helper for consistency

* Remove redundant if condition

We have `band  = drawBands && ...`, so there is no point checking if `drawBands` is true inside `if (band && ...)`.

* Skip plotting state value on statistic graph if units mismatch

For example plotting a *F sensor on a *C chart - statistic data will be converted to *C, but the state value will still be in *F so the displayed point is wrong. Similarly if plotting a kW sensor on a W chart, the same is true - statistics get converted to W by recorder, but the state value would still be in kW. In other words the plotted state point is complete nonsense.

If the units of the statistic state don't match the units of the graph, we should not be displaying the value on the graph.

* Remove redundant this.unit check

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-03-23 10:07:15 +01:00
WendelinandBram Kragten 10e8c2a148 Fix copy-to-clipboard in unsecure context (#30204) 2026-03-23 10:07:15 +01:00
Qusai IsmaelandBram Kragten e1a8616ab0 Fix missing conversation language picker in new pipeline dialog (#30194) 2026-03-23 10:07:14 +01:00
karwostsandBram Kragten ccdd71dd64 Fix tag dialog (#30191) 2026-03-23 10:07:13 +01:00
Petar PetrovandBram Kragten d3e1d55686 Fix negative monetary values displayed as positive (#30178) 2026-03-23 10:07:12 +01:00
Tom CarpenterandBram Kragten 4f916abcbf Remove duplicate final point in bar statistics-chart (#30175)
For bar charts, we don't need to close out the final segment. All this does is produce a duplicate final bar.
2026-03-23 10:07:11 +01:00
Joakim SørensenandBram Kragten 4548f9daae Fix passing click handler to ha-switch in cloudhooks section (#30166)
Fix passing clickhandler to ha-switch in cloudhooks section
2026-03-23 10:07:10 +01:00
Aidan TimsonandBram Kragten 4020bcec42 Fix event entity row propagation (#30163)
* Stop event entity row value propagation

* Catch interaction
2026-03-23 10:07:09 +01:00
Joakim SørensenandBram Kragten 22c0035e60 Fix formatting of ha-switch in cloud remote preferences panel (#30143) 2026-03-23 10:07:08 +01:00
Petar PetrovandBram Kragten 6b6ad8dd2c Preserve entity unit in gas and water flow rate badges (#30116)
* Preserve entity unit_of_measurement in gas and water flow rate badges

The gas and water total badges on the energy dashboard Now tab previously
converted all flow rate values to L/min and then formatted them as either
L/min or gal/min based on the unit system. This meant entities reporting
in m³/h or other units always displayed incorrectly.

Now the badges preserve the unit_of_measurement from the entities. If all
entities share the same unit, the raw values are summed directly. If they
differ, values are converted through L/min as an intermediate and displayed
in the first entity's unit.

* Extract shared computeTotalFlowRate to energy.ts
2026-03-23 10:07:07 +01:00
Bram Kragten 9c4aacdb1f Bumped version to 20260312.0 2026-03-12 22:08:30 +01:00
Paul BotteinandBram Kragten 3feb40a8f4 Add token for brands url in hassUrl helper (#30111) 2026-03-12 22:07:51 +01:00
Tom CarpenterandBram Kragten 7a310812e0 Fix energy dashboard date picker opening direction (#30090)
* Add Opening Direction to Date Picker Config

* Force date picker opening direction on energy dash
2026-03-12 22:07:50 +01:00
Aidan TimsonandBram Kragten ee77619da3 Fix code editor autocomplete using wa popup (#30081) 2026-03-12 22:07:49 +01:00
Petar PetrovandBram Kragten cfa8eb5370 Fix hasReturn check to scan all grid sources in energy view strategy (#30062) 2026-03-12 22:07:48 +01:00
Tom CarpenterandBram Kragten d9d2d6aa03 Don't include "null" data point in stat graph (#30058)
When displaying the "now" value on statistics graphs, don't include a "null" data point for sum/change type graphs, just skip entirely.

Otherwise for you get a messy null data point in the tooltip.
2026-03-12 22:07:47 +01:00
Tom CarpenterandBram Kragten 1f46f477c7 Add missing webawesome tooltip CSS variable (#30057)
* Correct missing ha-tooltip CSS variable

We were missing a default for the `--wa-tooltip-border-width` variable which meant the arrow from the tooltip disappeared in WA 3.3.1.

* Fix tooltip in ha-slider
2026-03-12 22:07:47 +01:00
Bram Kragten 52667b3266 Add reorder support to area selector (#30056) 2026-03-12 22:07:46 +01:00
Petar PetrovandBram Kragten c790d2356c Add back energy distribution card to electricity tab (#30049) 2026-03-12 22:07:44 +01:00
Yosi LevyandBram Kragten f24c009dd7 RTL textfield fixes for quick search (#30013)
textfield fixes for quick search
2026-03-12 22:07:44 +01:00
Petar PetrovandBram Kragten 8d42395938 Fix stale data point in history-graph cards with sub-hour windows (#29998)
Skip fetching hourly statistics when hours_to_show < 1 since hourly
aggregates produce stale outlier points in sub-hour chart windows
(e.g. hours_to_show: 0.1 or 0.05).

Also fix Date object handling in ha-chart-base downsampling bounds
extraction.
2026-03-12 22:07:42 +01:00
1a6d46a7ff Refactor tooltip CSS tokens to use ha- prefix (#29978)
Co-authored-by: Claude <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-03-12 22:07:42 +01:00
Petar PetrovandBram Kragten b286b07cfd Fix sensor card graph time axis not progressing when value is unchanged (#29976) 2026-03-12 22:07:41 +01:00
Paul BotteinandBram Kragten 1859d35f7b Add arrow and fix footer for vacuum segment mapper (#29975) 2026-03-12 22:07:40 +01:00
Bram Kragten 5709af57de Bumped version to 20260304.0 2026-03-04 12:42:58 +01:00
WendelinandBram Kragten bb16cc8c00 Open quick search quicker (#29967) 2026-03-04 12:42:26 +01:00
Bram Kragten 17c6dc52a8 Add hass url to brand images (#29961) 2026-03-04 12:42:24 +01:00
Paul BotteinandBram Kragten 1b8211db6d Align heading button font-size with other heading entity badge (#29958) 2026-03-04 12:42:22 +01:00
2b2bb77a2b Fix copy to clipboard for wa dialogs (#29951)
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-03-04 12:42:21 +01:00
WendelinandBram Kragten 64749350ef ha-bottom-sheet reduce motion support (#29950) 2026-03-04 12:42:20 +01:00
Paul BotteinandBram Kragten 043d4eed85 Add label for toggle button in area strategy (#29949) 2026-03-04 12:42:19 +01:00
Paul BotteinandBram Kragten 2f2e64bb1d Use max width for dashboard footer (#29947) 2026-03-04 12:39:18 +01:00
Petar PetrovandBram Kragten b74b02c09f Use net battery power in power sankey card (#29940) 2026-03-04 12:39:17 +01:00
WendelinandBram Kragten ab4c3a4316 ha-authorize fix rtl check (#29937)
Add RTL direction handling in updated lifecycle method
2026-03-04 12:39:16 +01:00
Bram Kragten 15de137591 Bumped version to 20260302.0 2026-03-02 17:08:01 +01:00
Paul BotteinandBram Kragten 465c10b945 Fix updates, discovered devices and repairs cards flickering (#29935) 2026-03-02 17:07:10 +01:00
Paul BotteinandBram Kragten 457c51cf58 Fix sidebar not closing when reduced motion is enabled (#29934) 2026-03-02 17:07:09 +01:00
WendelinandBram Kragten 640f2b9245 Dialog: Add show event target check (#29927)
Add event phase check in _handleShow and _handleAfterShow methods
2026-03-02 17:07:07 +01:00
Aidan TimsonandBram Kragten 852caa32be Remove cache to fix re-add repo issue (#29926)
Remove cache to fix readd repo issue
2026-03-02 17:07:06 +01:00
WendelinandBram Kragten 67ccfa0f6e Add error translation for loading energy preferences (#29924) 2026-03-02 17:07:05 +01:00
karwostsandBram Kragten c3cc566fe3 Fix distribution card stub error (#29915)
* Fix distribution card stub error

* unit check not required
2026-03-02 17:07:03 +01:00
Paul BotteinandBram Kragten 38d02a3f30 Fix control select menu color in ios (#29892) 2026-03-02 17:07:01 +01:00
Bram Kragten ad1d1e2260 Fix overflow for icon buttons (#29891) 2026-03-02 17:07:00 +01:00
Petar PetrovandBram Kragten b2eb8ec968 Make hui-sections-view always fill the screen so footer is at the bottom (#29890) 2026-03-02 17:06:59 +01:00
Petar PetrovandBram Kragten 7b8884f0fd Fix sensor card graph not updating when value is unchanged (#29889) 2026-03-02 17:06:57 +01:00
Petar PetrovandBram Kragten aff1fedc9d Fix monetary device class state display with non-ISO 4217 currency symbols (#29887) 2026-03-02 17:06:56 +01:00
Petar PetrovandBram Kragten 8f5059c24a Fix energy compare tooltip showing wrong year (#29885) 2026-03-02 17:06:54 +01:00
Aidan TimsonandBram Kragten 1e72ad1411 Code editor fullscreen in dialogs (#29882)
Co-authored-by: Bram Kragten <[email protected]>
2026-03-02 17:06:53 +01:00
Paul BotteinandBram Kragten c9f96bbe69 Add render icon property to ha-control-select-menu (#29881) 2026-03-02 17:06:52 +01:00
Aidan TimsonandBram Kragten 616c3d4657 Use large width on system log dialogs (#29879) 2026-03-02 17:06:50 +01:00
Robert ReschandBram Kragten b1ceece224 Revert "Add vacuum mapping not configured issue" (#29876) 2026-03-02 17:06:49 +01:00
Brandon ChenandBram Kragten d695c4c845 Fix YAML content invisible in dark mode for conversation debug result… (#29874) 2026-03-02 17:06:48 +01:00
Petar PetrovandBram Kragten fdbeb12622 Migrate Energy date selector to new footer (#29867) 2026-03-02 17:04:41 +01:00
Aidan TimsonandBram Kragten 29ede122a1 Add audits and yaml mode to more info details (#29854)
* Add audits and yaml mode to more info details

* Reset yaml mode on back

* Use mapped array for state entries

* Typo

Co-authored-by: Bram Kragten <[email protected]>

* Memoize

* Rename

* Fix

* Format audits in normal mode

* Refactor, dont pass hass

---------

Co-authored-by: Bram Kragten <[email protected]>
2026-03-02 17:04:40 +01:00
519d3d0e53 Fix data-table content bottom margin (#29805)
Co-authored-by: Copilot <[email protected]>
2026-03-02 17:04:39 +01:00
Bram Kragten 030a9a492c Bumped version to 20260226.0 2026-02-26 16:56:33 +01:00
Paul BotteinandBram Kragten 2685a007e7 Fix scrollbar in 2026.3 (#29865) 2026-02-26 16:55:15 +01:00
Aidan TimsonandBram Kragten 9ca1cfbf4a Add thread configuration my link (#29861) 2026-02-26 16:55:14 +01:00
Aidan TimsonandBram Kragten 0793af6846 Add matter configuration my link (#29859) 2026-02-26 16:55:13 +01:00
WendelinandBram Kragten bb7f441d8d Fix quick search icon size (#29858) 2026-02-26 16:55:12 +01:00
Aidan TimsonandBram Kragten 2813ed7938 Add missing theming variable support to dialog and bottom sheet (#29857) 2026-02-26 16:55:10 +01:00
WendelinandBram Kragten 9ebfa4029b Fix ha-icon-button-toggle selected style (#29856) 2026-02-26 16:55:10 +01:00
Aidan TimsonandBram Kragten 6190ba18ea Fix esc closing dialogs with prevent scrim close (#29851) 2026-02-26 16:55:09 +01:00
Petar PetrovandBram Kragten 81feea1109 Dynamically calculate the date range picker's vertical opening direction (#29850) 2026-02-26 16:55:08 +01:00
WendelinandBram Kragten be430931cc Fix protocols dashboards fab padding (#29847) 2026-02-26 16:55:07 +01:00
Petar PetrovandBram Kragten e07194027a Convert Energy Now tiles to badges (#29845) 2026-02-26 16:55:05 +01:00
Bram Kragten 17d9cd192f Bumped version to 20260225.0 2026-02-25 17:14:36 +01:00
229 changed files with 6178 additions and 3520 deletions
+3
View File
@@ -5,6 +5,9 @@ updates:
schedule:
interval: weekly
time: "06:00"
cooldown:
default-days-before-reopen: 30
default-days: 7
open-pull-requests-limit: 10
labels:
- Dependencies
+5
View File
@@ -8,6 +8,9 @@ on:
branches:
- master
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -24,6 +27,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -59,6 +63,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+9
View File
@@ -18,6 +18,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint and check format
@@ -25,6 +28,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -59,6 +64,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -77,6 +84,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
+8 -3
View File
@@ -7,6 +7,10 @@ on:
# The branches below must be a subset of the branches above
branches: [dev]
permissions:
contents: read
security-events: write
jobs:
analyze:
name: Analyze
@@ -28,6 +32,7 @@ jobs:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
persist-credentials: false
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
@@ -36,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
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@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+5
View File
@@ -9,6 +9,9 @@ on:
- dev
- master
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -25,6 +28,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -60,6 +64,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+5
View File
@@ -5,6 +5,9 @@ on:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -17,6 +20,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+5
View File
@@ -10,6 +10,9 @@ on:
branches:
- dev
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -22,6 +25,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1 -1
View File
@@ -1,6 +1,6 @@
name: "Pull Request Labeler"
on: pull_request_target
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
jobs:
triage:
+4
View File
@@ -5,6 +5,10 @@ on:
schedule:
- cron: "0 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
lock:
runs-on: ubuntu-latest
+2
View File
@@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+23 -9
View File
@@ -1,25 +1,39 @@
name: RelativeCI
on:
# zizmor: ignore[dangerous-triggers] -- safe: only downloads artifacts, no PR code checkout
workflow_run:
workflows: [CI]
types:
- completed
permissions:
contents: read
actions: read
jobs:
upload:
name: Upload stats
upload-frontend-modern:
name: Upload stats (frontend/modern)
if: ${{ github.event.workflow_run.conclusion == 'success' }}
strategy:
matrix:
bundle: [frontend]
build: [modern, legacy]
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
token: ${{ github.token }}
artifactName: ${{ format('{0}-bundle-stats', matrix.bundle) }}
webpackStatsFile: ${{ format('{0}-{1}.json', matrix.bundle, matrix.build) }}
artifactName: frontend-bundle-stats
webpackStatsFile: frontend-modern.json
upload-frontend-legacy:
name: Upload stats (frontend/legacy)
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
artifactName: frontend-bundle-stats
webpackStatsFile: frontend-legacy.json
+20 -14
View File
@@ -27,6 +27,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -34,13 +36,12 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master
uses: home-assistant/actions/helpers/verify-version@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
@@ -62,11 +63,10 @@ jobs:
skip-existing: true
- name: Upload release assets
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
files: |
dist/*.whl
dist/*.tar.gz
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" dist/*.whl dist/*.tar.gz --clobber
wheels-init:
name: Init wheels build
@@ -74,15 +74,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Generate requirements.txt
env:
GITHUB_REF: ${{ github.ref }}
run: |
# Sleep to give pypi time to populate the new version across mirrors
sleep 240
version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' )
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
echo "home-assistant-frontend==$version" > ./requirements.txt
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/[email protected]
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: cp314
tag: musllinux_1_2
@@ -99,11 +101,12 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
- name: Download Translations
@@ -113,8 +116,11 @@ jobs:
- name: Build landing-page
run: landing-page/script/build_landing_page
- name: Tar folder
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
env:
TAG_NAME: ${{ github.event.release.tag_name }}
run: tar -czf "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" -C landing-page/dist .
- name: Upload release asset
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" --clobber
+4
View File
@@ -5,6 +5,10 @@ on:
schedule:
- cron: "0 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
+5
View File
@@ -8,6 +8,9 @@ on:
paths:
- src/translations/en.json
permissions:
contents: read
jobs:
upload:
name: Upload
@@ -15,6 +18,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Upload Translations
run: |
+1 -1
View File
@@ -1 +1 @@
24.14.0
24.14.1
@@ -0,0 +1,82 @@
---
title: Input
---
# Input `<ha-input>`
A text input component supporting Home Assistant theming and validation, based on webawesome input.
Supports multiple input types including text, number, password, email, search, and more.
## Implementation
### Example usage
```html
<ha-input label="Name" value="Hello"></ha-input>
<ha-input label="Email" type="email" placeholder="[email protected]"></ha-input>
<ha-input label="Password" type="password" password-toggle></ha-input>
<ha-input label="Required" required></ha-input>
<ha-input label="Disabled" disabled value="Can't touch this"></ha-input>
```
### API
This component is based on the webawesome input component.
**Slots**
- `start`: Content placed before the input (usually for icons or prefixes).
- `end`: Content placed after the input (usually for icons or suffixes).
- `label`: Custom label content. Overrides the `label` property.
- `hint`: Custom hint content. Overrides the `hint` property.
- `clear-icon`: Custom clear icon.
- `show-password-icon`: Custom show password icon.
- `hide-password-icon`: Custom hide password icon.
**Properties/Attributes**
| Name | Type | Default | Description |
| -------------------- | ---------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------- |
| appearance | "material"/"outlined" | "material" | Sets the input appearance style. "material" is the default filled style, "outlined" uses a bordered style. |
| type | "text"/"number"/"password"/"email"/"search"/"tel"/"url"/"date"/"datetime-local"/"time"/"color" | "text" | Sets the input type. |
| value | String | - | The current value of the input. |
| label | String | "" | The input's label text. |
| hint | String | "" | The input's hint/helper text. |
| placeholder | String | "" | Placeholder text shown when the input is empty. |
| with-clear | Boolean | false | Adds a clear button when the input is not empty. |
| readonly | Boolean | false | Makes the input readonly. |
| disabled | Boolean | false | Disables the input and prevents user interaction. |
| required | Boolean | false | Makes the input a required field. |
| password-toggle | Boolean | false | Adds a button to toggle the password visibility. |
| without-spin-buttons | Boolean | false | Hides the browser's built-in spin buttons for number inputs. |
| auto-validate | Boolean | false | Validates the input on blur instead of on form submit. |
| invalid | Boolean | false | Marks the input as invalid. |
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
| pattern | String | - | A regular expression pattern to validate input against. |
| minlength | Number | - | The minimum length of input that will be considered valid. |
| maxlength | Number | - | The maximum length of input that will be considered valid. |
| min | Number/String | - | The input's minimum value. Only applies to date and number input types. |
| max | Number/String | - | The input's maximum value. Only applies to date and number input types. |
| step | Number/"any" | - | Specifies the granularity that the value must adhere to. |
**CSS Custom Properties**
- `--ha-input-padding-top` - Padding above the input.
- `--ha-input-padding-bottom` - Padding below the input. Defaults to `var(--ha-space-2)`.
- `--ha-input-text-align` - Text alignment of the input. Defaults to `start`.
- `--ha-input-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
---
## Derivatives
The following components extend or wrap `ha-input` for specific use cases:
- **`<ha-input-search>`** — A pre-configured search input with a magnify icon, clear button, and localized "Search" placeholder. Extends `ha-input`.
- **`<ha-input-copy>`** — A read-only input with a copy-to-clipboard button. Supports optional value masking with a reveal toggle.
- **`<ha-input-multi>`** — A dynamic list of text inputs for managing arrays of strings. Supports adding, removing, and drag-and-drop reordering.
+232
View File
@@ -0,0 +1,232 @@
import { ContextProvider } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/input/ha-input";
import "../../../../src/components/input/ha-input-copy";
import "../../../../src/components/input/ha-input-multi";
import "../../../../src/components/input/ha-input-search";
import { localizeContext } from "../../../../src/data/context";
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.copy": "Copy",
"ui.common.show": "Show",
"ui.common.hide": "Hide",
"ui.common.add": "Add",
"ui.common.remove": "Remove",
"ui.common.search": "Search",
"ui.common.copied_clipboard": "Copied to clipboard",
};
@customElement("demo-components-ha-input")
export class DemoHaInput extends LitElement {
constructor() {
super();
// Provides localizeContext for ha-input-copy, ha-input-multi and ha-input-search
// eslint-disable-next-line no-new
new ContextProvider(this, {
context: localizeContext,
initialValue: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
});
}
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-input in ${mode}">
<div class="card-content">
<h3>Basic</h3>
<div class="row">
<ha-input label="Default"></ha-input>
<ha-input label="With value" value="Hello"></ha-input>
<ha-input
label="With placeholder"
placeholder="Type here..."
></ha-input>
</div>
<h3>Input types</h3>
<div class="row">
<ha-input label="Text" type="text" value="Text"></ha-input>
<ha-input label="Number" type="number" value="42"></ha-input>
<ha-input
label="Email"
type="email"
placeholder="[email protected]"
></ha-input>
</div>
<div class="row">
<ha-input
label="Password"
type="password"
value="secret"
password-toggle
></ha-input>
<ha-input label="URL" type="url" placeholder="https://...">
</ha-input>
<ha-input label="Date" type="date"></ha-input>
</div>
<h3>States</h3>
<div class="row">
<ha-input
label="Disabled"
disabled
value="Disabled"
></ha-input>
<ha-input
label="Readonly"
readonly
value="Readonly"
></ha-input>
<ha-input label="Required" required></ha-input>
</div>
<div class="row">
<ha-input
label="Invalid"
invalid
validation-message="This field is required"
value=""
></ha-input>
<ha-input label="With hint" hint="This is a hint"></ha-input>
<ha-input
label="With clear"
with-clear
value="Clear me"
></ha-input>
</div>
<h3>With slots</h3>
<div class="row">
<ha-input label="With prefix">
<span slot="start">$</span>
</ha-input>
<ha-input label="With suffix">
<span slot="end">kg</span>
</ha-input>
<ha-input label="With icon">
<ha-svg-icon .path=${mdiMagnify} slot="start"></ha-svg-icon>
</ha-input>
</div>
<h3>Appearance: outlined</h3>
<div class="row">
<ha-input
appearance="outlined"
label="Outlined"
value="Hello"
></ha-input>
<ha-input
appearance="outlined"
label="Outlined disabled"
disabled
value="Disabled"
></ha-input>
<ha-input
appearance="outlined"
label="Outlined invalid"
invalid
validation-message="Required"
></ha-input>
</div>
<div class="row">
<ha-input
appearance="outlined"
placeholder="Placeholder only"
></ha-input>
</div>
</div>
</ha-card>
<ha-card header="Derivatives in ${mode}">
<div class="card-content">
<h3>ha-input-search</h3>
<ha-input-search label="Search label"></ha-input-search>
<ha-input-search appearance="outlined"></ha-input-search>
<h3>ha-input-copy</h3>
<ha-input-copy
value="my-api-token-123"
masked-value="••••••••••••••••••"
masked-toggle
></ha-input-copy>
<h3>ha-input-multi</h3>
<ha-input-multi
label="URL"
add-label="Add URL"
.value=${["https://example.com"]}
></ha-input-multi>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
h3 {
margin: var(--ha-space-4) 0 var(--ha-space-1) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
h3:first-child {
margin-top: 0;
}
.row {
display: flex;
gap: var(--ha-space-4);
}
.row > * {
flex: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-input": DemoHaInput;
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ The Home Assistant interface is based on Material Design. It's a design system c
We want to make it as easy for designers to contribute as it is for developers. Theres a lot a designer can contribute to:
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Start designing with our <a href="https://www.figma.com/community/file/967153512097289521/Home-Assistant-DesignKit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
## Developers
+8 -10
View File
@@ -30,7 +30,7 @@
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/language": "6.12.2",
"@codemirror/language": "6.12.3",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
@@ -82,7 +82,7 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.19",
"@swc/helpers": "0.5.20",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
@@ -129,7 +129,6 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.9",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
@@ -151,7 +150,7 @@
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.5",
"@rspack/core": "1.7.9",
"@rspack/core": "1.7.10",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -169,9 +168,8 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.0",
"@vitest/coverage-v8": "4.1.2",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -206,13 +204,13 @@
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.0.3",
"tar": "7.5.12",
"tar": "7.5.13",
"terser-webpack-plugin": "5.4.0",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"typescript-eslint": "8.57.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.0",
"vitest": "4.1.2",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
@@ -230,6 +228,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.14.0"
"node": "24.14.1"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260128.0"
version = "20260325.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+5 -3
View File
@@ -58,9 +58,11 @@ export class CastManager {
this._eventListeners[event].push(listener);
return () => {
this._eventListeners[event].splice(
this._eventListeners[event].indexOf(listener)
);
const listeners = this._eventListeners[event];
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
};
}
+28
View File
@@ -32,6 +32,12 @@ const YAML_ONLY_THEMES_COLORS = new Set([
"disabled",
]);
/**
* Compose a CSS variable out of a theme color
* @param color - Theme color (examples: `red`, `primary-text`)
* @returns CSS variable in `--xxx-color` format;
* initial color if not found in theme colors
*/
export function computeCssVariableName(color: string): string {
if (THEME_COLORS.has(color) || YAML_ONLY_THEMES_COLORS.has(color)) {
return `--${color}-color`;
@@ -39,6 +45,12 @@ export function computeCssVariableName(color: string): string {
return color;
}
/**
* Compose a CSS variable out of a theme color & then resolve it
* @param color - Theme color (examples: `red`, `primary-text`)
* @returns Resolved CSS variable in `var(--xxx-color)` format;
* initial color if not found in theme colors
*/
export function computeCssColor(color: string): string {
const cssVarName = computeCssVariableName(color);
if (cssVarName !== color) {
@@ -47,6 +59,22 @@ export function computeCssColor(color: string): string {
return color;
}
/**
* Get a color from document's styles
* @param color - Named theme color (examples: `red`, `primary-text`)
* @returns Resolved color; initial color if not found in document's styles
*/
export function resolveThemeColor(color: string): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = getComputedStyle(document.body)
.getPropertyValue(cssColor)
.trim();
return resolved || color;
}
return cssColor;
}
/**
* Validates if a string is a valid color.
* Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names.
+30 -12
View File
@@ -1,5 +1,6 @@
import colors from "color-name";
import { expandHex } from "./hex";
import { resolveThemeColor } from "./compute-color";
const rgb_hex = (component: number): string => {
const hex = Math.round(Math.min(Math.max(component, 0), 255)).toString(16);
@@ -130,26 +131,43 @@ export const rgb2hs = (rgb: [number, number, number]): [number, number] =>
export const hs2rgb = (hs: [number, number]): [number, number, number] =>
hsv2rgb([hs[0], hs[1], 255]);
export function theme2hex(themeColor: string): string {
if (themeColor.startsWith("#")) {
if (themeColor.length === 4 || themeColor.length === 5) {
const c = themeColor;
/**
* Attempt to get a HEX color from a color defined in different formats:
* HEX, rgb/rgba, named color
* @param color - Color (HEX, rgb/rgba, named color) to be converted to HEX
* @returns HEX color
*/
export function theme2hex(color: string): string {
// Attempting to find a HEX pattern in the input string
if (color.startsWith("#")) {
if (color.length === 4 || color.length === 5) {
const c = color;
// Convert short-form hex (#abc) to 6 digit (#aabbcc). Ignore alpha channel.
return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
}
if (themeColor.length === 9) {
if (color.length === 9) {
// Ignore alpha channel.
return themeColor.substring(0, 7);
return color.substring(0, 7);
}
return themeColor;
return color;
}
const rgbFromColorName = colors[themeColor.toLowerCase()];
if (rgbFromColorName) {
return rgb2hex(rgbFromColorName);
// Attempting to find a match in a HA Frontend theme colors
const themeColor = resolveThemeColor(color.toLowerCase());
if (themeColor !== color.toLowerCase()) {
// theme color is recognized, now re-attempt
return theme2hex(themeColor);
}
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
// Attempting to find a match in a web colors array
const rgbFromWebColor = colors[color.toLowerCase()];
if (rgbFromWebColor) {
// HEX color is recognized for the input named color
return rgb2hex(rgbFromWebColor);
}
// Attempting to find an RGB pattern in the input string
const rgbMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return rgb2hex([r, g, b]);
@@ -158,5 +176,5 @@ export function theme2hex(themeColor: string): string {
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return themeColor;
return color;
}
+11
View File
@@ -1,4 +1,5 @@
import { wcagLuminance, wcagContrast } from "culori";
import { theme2hex } from "./convert-color";
/**
* Calculates the luminosity of an RGB color.
@@ -48,3 +49,13 @@ export const getRGBContrastRatio = (
rgb1: [number, number, number],
rgb2: [number, number, number]
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
/**
* Returns a contrasted color (black or white) based on the luminance of another color
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
*/
export const getContrastedColorHex = (color: string): string => {
const lum = wcagLuminance(theme2hex(color));
return lum > 0.5 ? "#000000" : "#ffffff";
};
+40 -10
View File
@@ -4,11 +4,14 @@ import type {
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { computeDeviceName } from "./compute_device_name";
import { computeStateName } from "./compute_state_name";
import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"]
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = entities[stateObj.entity_id] as
| EntityRegistryDisplayEntry
@@ -18,22 +21,49 @@ export const computeEntityName = (
// Fall back to state name if not in the entity registry (friendly name)
return computeStateName(stateObj);
}
return computeEntityEntryName(entry);
return computeEntityEntryName(entry, devices);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
devices: HomeAssistant["devices"],
fallbackStateObj?: HassEntity
): string | undefined => {
if (entry.name != null) {
return entry.name;
const name =
entry.name ||
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
const device = entry.device_id ? devices[entry.device_id] : undefined;
if (!device) {
if (name) {
return name;
}
if (fallbackStateObj) {
return computeStateName(fallbackStateObj);
}
return undefined;
}
if ("original_name" in entry && entry.original_name != null) {
return String(entry.original_name);
const deviceName = computeDeviceName(device);
// If the device name is the same as the entity name, consider empty entity name
if (deviceName === name) {
return undefined;
}
return undefined;
// Remove the device name from the entity name if it starts with it
if (deviceName && name) {
return stripPrefixFromEntityName(name, deviceName) || name;
}
return name;
};
export const entityUseDeviceName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"]
): boolean => !computeEntityName(stateObj, entities);
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): boolean => !computeEntityName(stateObj, entities, devices);
@@ -5,6 +5,7 @@ import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
@@ -29,14 +30,23 @@ export interface EntityNameOptions {
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[] | undefined,
name: string | EntityNameItem | EntityNameItem[] | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameOptions
) => {
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
if (typeof name === "string") {
return name;
}
// If no name config is provided, fall back to the friendly name
if (!name) {
return computeStateName(stateObj);
}
let items = ensureArray(name);
const separator = options?.separator ?? DEFAULT_SEPARATOR;
@@ -45,7 +55,7 @@ export const computeEntityNameDisplay = (
return items.map((item) => item.text).join(separator);
}
const useDeviceName = entityUseDeviceName(stateObj, entities);
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
// If entity uses device name, and device is not already included, replace it with device name
if (useDeviceName) {
@@ -91,7 +101,7 @@ export const computeEntityNameList = (
const names = name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityName(stateObj, entities);
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "area":
+4 -2
View File
@@ -142,9 +142,10 @@ const computeStateToPartsFromEntityAttributes = (
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
minusSign: "value",
};
const valueParts: ValuePart[] = [];
@@ -153,7 +154,7 @@ const computeStateToPartsFromEntityAttributes = (
const type = TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
@@ -254,6 +255,7 @@ const computeStateToPartsFromEntityAttributes = (
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"scene",
+3
View File
@@ -29,6 +29,7 @@ export const FIXED_DOMAIN_STATES = {
device_tracker: ["home", "not_home"],
fan: ["on", "off"],
humidifier: ["on", "off"],
infrared: [],
input_boolean: ["on", "off"],
input_button: [],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
@@ -270,6 +271,8 @@ export const getStates = (
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "device_tracker":
+3 -1
View File
@@ -6,7 +6,9 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
const domain = computeDomain(stateObj.entity_id);
const compareState = state !== undefined ? state : stateObj?.state;
if (["button", "event", "input_button", "scene"].includes(domain)) {
if (
["button", "event", "infrared", "input_button", "scene"].includes(domain)
) {
return compareState !== UNAVAILABLE;
}
@@ -0,0 +1,11 @@
/**
* Indicates whether the current browser supports the Popover API.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
*/
export const popoverSupported = globalThis?.HTMLElement?.prototype
? Object.prototype.hasOwnProperty.call(
globalThis.HTMLElement.prototype,
"popover"
)
: false;
-7
View File
@@ -71,13 +71,6 @@ export const formatNumberToParts = (
? numberFormatToLocale(localeOptions)
: undefined;
// Polyfill for Number.isNaN, which is more reliable than the global isNaN()
Number.isNaN =
Number.isNaN ||
function isNaN(input) {
return typeof input === "number" && isNaN(input);
};
if (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))
+29
View File
@@ -5,12 +5,41 @@ import {
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayShort,
formatDateYear,
} from "../../common/datetime/format_date";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
export function getPeriodicAxisLabelConfig(
period: string,
locale: FrontendLocaleData,
config: HassConfig
):
| {
formatter: (value: number) => string;
}
| undefined {
if (period === "month") {
return {
formatter: (value: number) => {
const date = new Date(value);
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
},
};
}
if (period === "year") {
return {
formatter: (value: number) =>
formatDateYear(new Date(value), locale, config),
};
}
return undefined;
}
export function formatTimeLabel(
value: number | Date,
locale: FrontendLocaleData,
+162 -24
View File
@@ -91,6 +91,10 @@ export class HaChartBase extends LitElement {
private _lastTapTime?: number;
private _longPressTimer?: ReturnType<typeof setTimeout>;
private _longPressTriggered = false;
private _shouldResizeChart = false;
private _resizeAnimationDuration?: number;
@@ -128,6 +132,7 @@ export class HaChartBase extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._legendPointerCancel();
this._pendingSetup = false;
while (this._listeners.length) {
this._listeners.pop()!();
@@ -280,39 +285,53 @@ export class HaChartBase extends LitElement {
<div class="chart"></div>
</div>
${this._renderLegend()}
<div class="chart-controls ${classMap({ small: this.smallControls })}">
${this._isZoomed && !this.hideResetButton
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
<div
class="chart-controls ${classMap({ small: this.smallControls })}"
>
${this._isZoomed && !this.hideResetButton
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div>
</div>
`;
}
private _renderLegend() {
private _getLegendItems() {
if (!this.options?.legend || !this.data) {
return nothing;
return undefined;
}
const legend = ensureArray(this.options.legend).find(
(l) => l.show && l.type === "custom"
) as CustomLegendOption | undefined;
if (!legend) {
return nothing;
return undefined;
}
const datasets = ensureArray(this.data);
const items =
return (
legend.data ||
datasets
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
.map((d) => ({ id: d.id, name: d.name }));
.map((d) => ({ id: d.id, name: d.name }))
);
}
private _renderLegend() {
const items = this._getLegendItems();
if (!items) {
return nothing;
}
const datasets = ensureArray(this.data!);
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -357,6 +376,11 @@ export class HaChartBase extends LitElement {
return html`<li
.id=${id}
@click=${this._legendClick}
@pointerdown=${this._legendPointerDown}
@pointerup=${this._legendPointerCancel}
@pointerleave=${this._legendPointerCancel}
@pointercancel=${this._legendPointerCancel}
@contextmenu=${this._legendContextMenu}
class=${classMap({ hidden: this._hiddenDatasets.has(id) })}
.title=${name}
>
@@ -627,7 +651,7 @@ export class HaChartBase extends LitElement {
hideOverlap: true,
...axis.axisLabel,
},
minInterval,
minInterval: axis.minInterval ?? minInterval,
} as XAXisOption;
});
}
@@ -1017,11 +1041,52 @@ export class HaChartBase extends LitElement {
fireEvent(this, "chart-zoom", { start, end });
}
private _legendClick(ev: any) {
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
private _legendPointerDown(ev: PointerEvent) {
// Mouse uses Ctrl/Cmd+click instead
if (ev.pointerType === "mouse") {
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
if (!id) {
return;
}
this._longPressTriggered = false;
this._longPressTimer = setTimeout(() => {
this._longPressTriggered = true;
this._longPressTimer = undefined;
this._soloLegend(id);
}, 500);
}
private _legendPointerCancel() {
if (this._longPressTimer) {
clearTimeout(this._longPressTimer);
this._longPressTimer = undefined;
}
}
private _legendContextMenu(ev: Event) {
if (this._longPressTimer || this._longPressTriggered) {
ev.preventDefault();
}
}
private _legendClick(ev: MouseEvent) {
if (!this.chart) {
return;
}
const id = ev.currentTarget?.id;
if (this._longPressTriggered) {
this._longPressTriggered = false;
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
// Cmd+click on Mac (Ctrl+click is right-click there), Ctrl+click elsewhere
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
if (soloModifier) {
this._soloLegend(id);
return;
}
if (this._hiddenDatasets.has(id)) {
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
this._hiddenDatasets.delete(i)
@@ -1036,6 +1101,60 @@ export class HaChartBase extends LitElement {
this.requestUpdate("_hiddenDatasets");
}
private _soloLegend(id: string) {
const allIds = this._getAllLegendIds();
const clickedIds = this._getAllIdsFromLegend(this.options, id);
const otherIds = allIds.filter((i) => !clickedIds.includes(i));
const clickedIsOnlyVisible =
clickedIds.every((i) => !this._hiddenDatasets.has(i)) &&
otherIds.every((i) => this._hiddenDatasets.has(i));
if (clickedIsOnlyVisible) {
// Already solo'd on this item — restore all series to visible
for (const hiddenId of [...this._hiddenDatasets]) {
this._hiddenDatasets.delete(hiddenId);
fireEvent(this, "dataset-unhidden", { id: hiddenId });
}
} else {
// Solo: hide every other series, unhide clicked if it was hidden
for (const otherId of otherIds) {
if (!this._hiddenDatasets.has(otherId)) {
this._hiddenDatasets.add(otherId);
fireEvent(this, "dataset-hidden", { id: otherId });
}
}
for (const clickedId of clickedIds) {
if (this._hiddenDatasets.has(clickedId)) {
this._hiddenDatasets.delete(clickedId);
fireEvent(this, "dataset-unhidden", { id: clickedId });
}
}
}
this.requestUpdate("_hiddenDatasets");
}
private _getAllLegendIds(): string[] {
const items = this._getLegendItems();
if (!items) {
return [];
}
const allIds = new Set<string>();
for (const item of items) {
const primaryId =
typeof item === "string"
? item
: ((item.id as string) ?? (item.name as string) ?? "");
for (const expandedId of this._getAllIdsFromLegend(
this.options,
primaryId
)) {
allIds.add(expandedId);
}
}
return [...allIds];
}
private _toggleExpandedLegend() {
this.expandLegend = !this.expandLegend;
setTimeout(() => {
@@ -1116,16 +1235,35 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart-controls {
.top-controls {
position: absolute;
top: 16px;
right: 4px;
top: var(--ha-space-4);
inset-inline-start: var(--ha-space-4);
inset-inline-end: var(--ha-space-1);
display: flex;
align-items: flex-start;
gap: var(--ha-space-2);
z-index: 1;
pointer-events: none;
}
::slotted([slot="search"]) {
flex: 1 1 250px;
min-width: 0;
max-width: 250px;
pointer-events: auto;
}
.chart-controls {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-inline-start: auto;
flex-shrink: 0;
pointer-events: auto;
}
.top-controls.small {
top: 0;
}
.chart-controls.small {
top: 0;
flex-direction: row;
}
.chart-controls ha-icon-button,
+140 -6
View File
@@ -1,7 +1,9 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type {
CallbackDataParams,
TopLevelFormatterParams,
@@ -63,6 +65,8 @@ export interface NetworkData {
categories?: { name: string; symbol: string }[];
}
const PHYSICS_DISABLE_THRESHOLD = 512;
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@@ -76,11 +80,23 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
params: TopLevelFormatterParams
) => string;
/**
* Optional callback that returns additional searchable strings for a node.
* These are matched against the search filter in addition to the node's name and context.
*/
@property({ attribute: false }) public searchableAttributes?: (
nodeId: string
) => string[];
@property({ attribute: false }) public searchFilter = "";
public hass!: HomeAssistant;
@state() private _highlightedNodes?: Set<string>;
@state() private _reducedMotion = false;
@state() private _physicsEnabled = true;
@state() private _physicsEnabled?: boolean;
@state() private _showLabels = true;
@@ -108,6 +124,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
];
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (this._physicsEnabled === undefined && this.data?.nodes?.length > 1) {
this._physicsEnabled =
this.data.nodes.length <= PHYSICS_DISABLE_THRESHOLD;
}
}
protected render() {
if (!GraphChart || !this.data.nodes?.length) {
return nothing;
@@ -117,19 +141,24 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const hasHighlightedNodes =
this._highlightedNodes && this._highlightedNodes.size > 0;
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._getSeries(
this.data,
this._physicsEnabled,
this._physicsEnabled ?? false,
this._reducedMotion,
this._showLabels,
isMobile
isMobile,
hasHighlightedNodes
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
>
<slot name="search" slot="search"></slot>
<slot name="button" slot="button"></slot>
<ha-icon-button
slot="button"
@@ -165,7 +194,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
...category,
icon: category.symbol,
})),
top: 8,
bottom: 8,
},
dataZoom: {
type: "inside",
@@ -175,13 +204,56 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
deepEqual
);
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("searchFilter")) {
const filter = this.searchFilter;
if (!filter) {
this._highlightedNodes = undefined;
} else {
const lowerFilter = filter.toLowerCase();
const matchingIds = new Set<string>();
for (const node of this.data.nodes) {
if (this._nodeMatchesFilter(node, lowerFilter)) {
matchingIds.add(node.id);
}
}
this._highlightedNodes = matchingIds;
}
this._applyHighlighting();
this._updateMouseoverHandler();
}
}
private _nodeMatchesFilter(node: NetworkNode, lowerFilter: string): boolean {
if (node.name?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (node.context?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (node.id?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (this.searchableAttributes) {
const extraValues = this.searchableAttributes(node.id);
for (const value of extraValues) {
if (value?.toLowerCase().includes(lowerFilter)) {
return true;
}
}
}
return false;
}
private _getSeries = memoizeOne(
(
data: NetworkData,
physicsEnabled: boolean,
reducedMotion: boolean,
showLabels: boolean,
isMobile: boolean
isMobile: boolean,
hasHighlightedNodes?: boolean
) => ({
id: "network",
type: "graph",
@@ -214,7 +286,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
},
},
emphasis: {
focus: isMobile ? "none" : "adjacency",
focus: hasHighlightedNodes ? "self" : isMobile ? "none" : "adjacency",
},
force: {
repulsion: [400, 600],
@@ -362,6 +434,68 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
});
}
private _applyHighlighting() {
const chart = this._baseChart?.chart;
if (!chart) {
return;
}
// Reset all nodes to normal opacity first
chart.dispatchAction({ type: "downplay" });
const highlighted = this._highlightedNodes;
if (!highlighted || highlighted.size === 0) {
return;
}
const dataIndices: number[] = [];
this.data.nodes.forEach((node, index) => {
if (highlighted.has(node.id)) {
dataIndices.push(index);
}
});
if (dataIndices.length > 0) {
chart.dispatchAction({ type: "highlight", dataIndex: dataIndices });
}
}
private _emphasisGuardHandler?: () => void;
private _updateMouseoverHandler() {
const chart = this._baseChart?.chart;
if (!chart) {
return;
}
// When there are highlighted nodes, re-apply highlighting on hover
// and mouseout to prevent hover from overriding the search state
if (this._highlightedNodes && this._highlightedNodes.size > 0) {
if (this._emphasisGuardHandler) {
// Guard already set
return;
}
this._emphasisGuardHandler = () => {
this._applyHighlighting();
};
chart.on("mouseover", this._emphasisGuardHandler);
chart.on("mouseout", this._emphasisGuardHandler);
} else {
if (!this._emphasisGuardHandler) {
return;
}
chart.off("mouseover", this._emphasisGuardHandler);
chart.off("mouseout", this._emphasisGuardHandler);
this._emphasisGuardHandler = undefined;
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._emphasisGuardHandler) {
this._baseChart?.chart?.off("mouseover", this._emphasisGuardHandler);
this._baseChart?.chart?.off("mouseout", this._emphasisGuardHandler);
this._emphasisGuardHandler = undefined;
}
}
private _togglePhysics() {
this._saveNodePositions();
this._physicsEnabled = !this._physicsEnabled;
@@ -28,6 +28,13 @@ const safeParseFloat = (value) => {
return isFinite(parsed) ? parsed : null;
};
const CLIMATE_MODE_CONFIGS = [
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
@customElement("state-history-chart-line")
export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -429,23 +436,18 @@ export class StateHistoryChartLine extends LitElement {
(entityState) => entityState.attributes?.hvac_action
);
const isHeating =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "heat"
: (entityState: LineChartState) => entityState.state === "heat";
const isCooling =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "cool"
: (entityState: LineChartState) => entityState.state === "cool";
const hasHeat = states.states.some(isHeating);
const hasCool = states.states.some(isCooling);
const activeModes = CLIMATE_MODE_CONFIGS.map(
({ mode, action, cssVar }) => {
const isActive =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === mode
: (entityState: LineChartState) => entityState.state === mode;
return { action, cssVar, isActive };
}
).filter(({ isActive }) => states.states.some(isActive));
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
@@ -466,33 +468,19 @@ export class StateHistoryChartLine extends LitElement {
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
if (hasHeat) {
for (const { action, cssVar } of activeModes) {
addDataSet(
states.entity_id + "-heating",
`${states.entity_id}-${action}`,
this.showNames
? this.hass.localize("ui.card.climate.heating", { name: name })
? this.hass.localize(`ui.card.climate.${action}`, {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
),
computedStyles.getPropertyValue("--state-climate-heat-color"),
computedStyles.getPropertyValue(cssVar),
true
);
// The "heating" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasCool) {
addDataSet(
states.entity_id + "-cooling",
this.showNames
? this.hass.localize("ui.card.climate.cooling", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
),
computedStyles.getPropertyValue("--state-climate-cool-color"),
true
);
// The "cooling" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasTargetRange) {
@@ -540,11 +528,8 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.current_temperature
);
const series = [curTemp];
if (hasHeat) {
series.push(isHeating(entityState) ? curTemp : null);
}
if (hasCool) {
series.push(isCooling(entityState) ? curTemp : null);
for (const { isActive } of activeModes) {
series.push(isActive(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
+17
View File
@@ -32,6 +32,7 @@ import {
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
@@ -293,6 +294,22 @@ export class StatisticsChart extends LitElement {
type: "time",
min: startTime,
max: this.endTime,
...(this.period === "month" && {
minInterval: 28 * 24 * 3600 * 1000,
axisLabel: getPeriodicAxisLabelConfig(
"month",
this.hass.locale,
this.hass.config
),
}),
...(this.period === "year" && {
minInterval: 365 * 24 * 3600 * 1000,
axisLabel: getPeriodicAxisLabelConfig(
"year",
this.hass.locale,
this.hass.config
),
}),
},
{
id: "hiddenAxis",
@@ -2,7 +2,6 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare";
@@ -53,16 +52,15 @@ class HaDataTableLabels extends LitElement {
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
const color = label?.color ? computeCssColor(label.color) : undefined;
return html`
<ha-label
dense
role="button"
tabindex="0"
.color=${label.color}
.item=${label}
@click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label?.icon
@@ -102,10 +100,6 @@ class HaDataTableLabels extends LitElement {
position: fixed;
flex-wrap: nowrap;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
.plus {
--ha-label-background-color: transparent;
border: 1px solid var(--divider-color);
+10 -12
View File
@@ -27,7 +27,7 @@ import type { HomeAssistant } from "../../types";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon";
import "../search-input";
import "../input/ha-input-search";
import { filterData, sortData } from "./sort-filter";
export interface RowClickedEvent {
@@ -391,11 +391,11 @@ export class HaDataTable extends LitElement {
${this._filterable
? html`
<div class="table-header">
<search-input
.hass=${this.hass}
@value-changed=${this._handleSearchChange}
.label=${this.searchLabel}
></search-input>
<ha-input-search
appearance="outlined"
@input=${this._handleSearchChange}
.placeholder=${this.searchLabel}
></ha-input-search>
</div>
`
: ""}
@@ -970,12 +970,12 @@ export class HaDataTable extends LitElement {
});
}
private _handleSearchChange(ev: CustomEvent): void {
private _handleSearchChange(ev: InputEvent): void {
if (this.filter) {
return;
}
this._lastSelectedRowId = null;
this._debounceSearch(ev.detail.value);
this._debounceSearch((ev.target as HTMLInputElement).value);
}
private async _calcTableHeight() {
@@ -1388,11 +1388,9 @@ export class HaDataTable extends LitElement {
.table-header {
border-bottom: 1px solid var(--divider-color);
}
search-input {
display: block;
ha-input-search {
flex: 1;
--mdc-text-field-fill-color: var(--sidebar-background-color);
--mdc-text-field-idle-line-color: transparent;
padding: var(--ha-space-3);
}
slot[name="header"] {
display: block;
+38 -14
View File
@@ -4,7 +4,7 @@ import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
@@ -29,6 +29,7 @@ import "../ha-list-item";
import "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
import { datePickerStyles, dateRangePickerStyles } from "./styles";
import type { HaTimeInput } from "../ha-time-input";
@customElement("date-range-picker")
export class DateRangePicker extends LitElement {
@@ -69,6 +70,8 @@ export class DateRangePicker extends LitElement {
to: { hours: 23, minutes: 59 },
};
@queryAll("ha-time-input") private _timeInputs?: NodeListOf<HaTimeInput>;
public connectedCallback() {
super.connectedCallback();
@@ -153,6 +156,7 @@ export class DateRangePicker extends LitElement {
)}
id="from"
placeholder-labels
auto-validate
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
@@ -163,6 +167,7 @@ export class DateRangePicker extends LitElement {
)}
id="to"
placeholder-labels
auto-validate
></ha-time-input>
</div>
`
@@ -200,6 +205,14 @@ export class DateRangePicker extends LitElement {
let endDate = new Date(`${dates[1]}T23:59:00`);
if (this.timePicker) {
const timeInputs = this._timeInputs;
if (
timeInputs &&
![...timeInputs].every((input) => input.reportValidity())
) {
// If we have time inputs, and they don't all report valid, don't save
return;
}
startDate.setHours(this._timeValue.from.hours);
startDate.setMinutes(this._timeValue.from.minutes);
endDate.setHours(this._timeValue.to.hours);
@@ -261,12 +274,6 @@ export class DateRangePicker extends LitElement {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._dateValue = formatCallyDateRange(
dateRange[0],
dateRange[1],
this.locale,
this.hassConfig
);
fireEvent(this, "value-changed", {
value: {
startDate: dateRange[0],
@@ -281,7 +288,8 @@ export class DateRangePicker extends LitElement {
private _handleChangeTime(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const time = ev.detail.value;
const type = (ev.target as HaBaseTimeInput).id;
const target = ev.target as HaBaseTimeInput;
const type = target.id;
if (time) {
if (!this._timeValue) {
this._timeValue = {
@@ -301,17 +309,39 @@ export class DateRangePicker extends LitElement {
css`
.picker {
display: flex;
flex-direction: row;
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
min-width: 140px;
flex: 0 1 30%;
}
.range {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: var(--ha-space-3);
overflow-x: hidden;
}
@media only screen and (max-width: 460px) {
.picker {
flex-direction: column;
}
.date-range-ranges {
flex-basis: 180px;
border-bottom: 1px solid var(--divider-color);
border-right: none;
overflow-y: scroll;
}
.range {
flex-basis: fit-content;
}
}
.times {
@@ -326,12 +356,6 @@ export class DateRangePicker extends LitElement {
padding: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
}
@media only screen and (max-width: 500px) {
.date-range-ranges {
max-width: 30%;
}
}
`,
];
}
-27
View File
@@ -80,33 +80,6 @@ export const datePickerStyles = css`
text-align: center;
margin-left: 48px;
}
@media only screen and (max-width: 500px) {
calendar-month {
min-height: calc(34px * 7);
}
calendar-month::part(day) {
font-size: var(--ha-font-size-s);
}
calendar-month::part(button) {
height: 26px;
width: 26px;
}
calendar-month::part(range-inner),
calendar-month::part(range-start),
calendar-month::part(range-end),
calendar-month::part(selected),
calendar-month::part(selected):hover {
height: 34px;
width: 34px;
}
.heading {
font-size: var(--ha-font-size-s);
}
.month-year {
margin-left: 40px;
}
}
`;
export const dateRangePickerStyles = css`
+322 -220
View File
@@ -1,15 +1,13 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import {
DEFAULT_ENTITY_NAME,
type EntityNameItem,
} from "../../common/entity/compute_entity_name_display";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
@@ -17,12 +15,14 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable";
import "../input/ha-input";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
@@ -73,10 +73,291 @@ export class HaEntityNamePicker extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@state() private _mode?: "composed" | "custom";
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _editIndex?: number;
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated) {
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
}
protected willUpdate(_changedProperties: PropertyValues): void {
if (this._mode === undefined) {
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
}
protected render() {
const modeButtons = [
{
label: this.hass.localize(
"ui.components.entity.entity-name-picker.mode_composed"
),
value: "composed",
},
{
label: this.hass.localize(
"ui.components.entity.entity-name-picker.mode_custom"
),
value: "custom",
},
];
return html`
<div class="container">
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-button-toggle-group
size="small"
.buttons=${modeButtons}
.active=${this._mode}
.disabled=${this.disabled}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>
</div>
<div class="content">
${this._mode === "custom"
? this._renderTextInput()
: this._renderPicker()}
</div>
</div>
${this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing}
`;
}
private _renderTextInput() {
const items = this._items;
const value =
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
return html`
<ha-input
.disabled=${this.disabled}
.required=${this.required}
.value=${value}
@input=${this._textInputChanged}
></ha-input>
`;
}
private _renderPicker() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
`;
}
private _modeChanged(ev: CustomEvent) {
ev.stopPropagation();
this._mode = ev.detail.value as "composed" | "custom";
}
private _textInputChanged(ev: Event) {
const value = (ev.target as HTMLInputElement).value;
const newValue: EntityNameItem[] = value
? [{ type: "text", text: value }]
: [];
this._setValue(newValue);
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
this._editIndex = undefined;
} else {
newValue.push(item);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return undefined;
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
private _validTypes = memoizeOne((entityId?: string) => {
const options = new Set<string>(["text"]);
if (!entityId) {
@@ -161,157 +442,6 @@ export class HaEntityNamePicker extends LitElement {
})
);
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
protected render() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [...DEFAULT_ENTITY_NAME];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return "";
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this._items[this._editIndex];
@@ -362,58 +492,6 @@ export class HaEntityNamePicker extends LitElement {
return filteredItems;
};
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
this._editIndex = undefined;
} else {
newValue.push(item);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
@@ -421,13 +499,42 @@ export class HaEntityNamePicker extends LitElement {
}
.container {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
label {
display: block;
font-weight: 500;
}
.content {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
}
ha-generic-picker,
ha-input {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
.field:after {
display: block;
content: "";
position: absolute;
@@ -445,30 +552,25 @@ export class HaEntityNamePicker extends LitElement {
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
ha-chip-set {
padding: var(--ha-space-3);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2) var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
+2 -3
View File
@@ -1,11 +1,10 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { HomeAssistant } from "../../types";
import "../ha-relative-time";
import "./state-badge";
import "../ha-tooltip";
import "./state-badge";
@customElement("state-info")
class StateInfo extends LitElement {
@@ -22,7 +21,7 @@ class StateInfo extends LitElement {
return nothing;
}
const name = computeStateName(this.stateObj);
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
return html`<state-badge
.hass=${this.hass}
-1
View File
@@ -21,7 +21,6 @@ class AliasesEditor extends LitElement {
return html`
<ha-input-multi
.hass=${this.hass}
.value=${this.aliases}
.disabled=${this.disabled}
.sortable=${this.sortable}
+4 -1
View File
@@ -56,7 +56,10 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return parts.find((part) => part.type === "value")?.value;
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
}
return this.hass.formatEntityAttributeValue(this.stateObj!, this.attribute);
+5 -3
View File
@@ -137,7 +137,7 @@ export class HaBaseTimeInput extends LitElement {
@property({ attribute: "placeholder-labels", type: Boolean })
public placeholderLabels = false;
@queryAll("ha-input") private _inputs?: HaInput[];
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
@@ -145,7 +145,9 @@ export class HaBaseTimeInput extends LitElement {
};
public reportValidity(): boolean {
return this._inputs?.every((input) => input.reportValidity()) ?? true;
const inputs = this._inputs;
if (!inputs) return true;
return [...inputs].every((input) => input.reportValidity());
}
protected render(): TemplateResult {
@@ -399,7 +401,7 @@ export class HaBaseTimeInput extends LitElement {
.time-separator,
ha-icon-button {
background-color: var(--ha-color-fill-neutral-quiet-resting);
background-color: var(--ha-color-form-background);
color: var(--ha-color-text-secondary);
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
box-sizing: border-box;
+1
View File
@@ -57,6 +57,7 @@ export class HaButton extends Button {
.button {
font-size: var(--ha-font-size-m);
line-height: 1;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--ha-animation-duration-fast)
ease-out;
+3
View File
@@ -100,6 +100,9 @@ export class HaDropdown extends Dropdown {
#menu {
padding: var(--ha-space-1);
}
wa-popup::part(popup) {
z-index: 200;
}
`,
];
}
+11 -9
View File
@@ -15,7 +15,8 @@ import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
import "./search-input-outlined";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@@ -67,12 +68,12 @@ export class HaFilterDevices extends LitElement {
: nothing}
</div>
${this._shouldRender
? html`<search-input-outlined
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</search-input-outlined>
</ha-input-search>
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._devices(
@@ -138,8 +139,9 @@ export class HaFilterDevices extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value.toLowerCase();
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
private _devices = memoizeOne(
@@ -249,7 +251,7 @@ export class HaFilterDevices extends LitElement {
ha-check-list-item {
width: 100%;
}
search-input-outlined {
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
+11 -9
View File
@@ -14,7 +14,8 @@ import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-list";
import "./search-input-outlined";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-domains")
export class HaFilterDomains extends LitElement {
@@ -49,12 +50,12 @@ export class HaFilterDomains extends LitElement {
: nothing}
</div>
${this._shouldRender
? html`<search-input-outlined
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</search-input-outlined>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@click=${this._handleItemClick}
@@ -155,8 +156,9 @@ export class HaFilterDomains extends LitElement {
});
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value.toLowerCase();
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
@@ -201,7 +203,7 @@ export class HaFilterDomains extends LitElement {
padding: 0px 2px;
color: var(--text-primary-color);
}
search-input-outlined {
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
+11 -9
View File
@@ -17,7 +17,8 @@ import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
import "./ha-state-icon";
import "./search-input-outlined";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@@ -70,12 +71,12 @@ export class HaFilterEntities extends LitElement {
</div>
${this._shouldRender
? html`
<search-input-outlined
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</search-input-outlined>
</ha-input-search>
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._entities(
@@ -149,8 +150,9 @@ export class HaFilterEntities extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value.toLowerCase();
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
private _entities = memoizeOne(
@@ -265,7 +267,7 @@ export class HaFilterEntities extends LitElement {
--mdc-list-item-graphic-margin: 16px;
width: 100%;
}
search-input-outlined {
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
+11 -9
View File
@@ -15,7 +15,8 @@ import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-list";
import "./search-input-outlined";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@@ -52,12 +53,12 @@ export class HaFilterIntegrations extends LitElement {
: nothing}
</div>
${this._manifests && this._shouldRender
? html`<search-input-outlined
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</search-input-outlined>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@click=${this._handleItemClick}
@@ -175,8 +176,9 @@ export class HaFilterIntegrations extends LitElement {
});
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value.toLowerCase();
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
@@ -221,7 +223,7 @@ export class HaFilterIntegrations extends LitElement {
padding: 0px 2px;
color: var(--text-primary-color);
}
search-input-outlined {
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
+16 -23
View File
@@ -6,12 +6,11 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import { labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
@@ -21,7 +20,8 @@ import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./ha-list-item";
import "./search-input-outlined";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-labels")
export class HaFilterLabels extends LitElement {
@@ -79,12 +79,12 @@ export class HaFilterLabels extends LitElement {
: nothing}
</div>
${this._shouldRender
? html`<search-input-outlined
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</search-input-outlined>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
@@ -97,17 +97,14 @@ export class HaFilterLabels extends LitElement {
this.value
),
(label) => label.label_id,
(label) => {
const color = label.color
? computeCssColor(label.color)
: undefined;
return html`<ha-check-list-item
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
style=${color ? `--color: ${color}` : ""}
.color=${label.color}
.description=${label.description}
>
${label.icon
@@ -118,8 +115,7 @@ export class HaFilterLabels extends LitElement {
: nothing}
${label.name}
</ha-label>
</ha-check-list-item>`;
}
</ha-check-list-item>`
)}
</ha-list> `
: nothing}
@@ -163,8 +159,9 @@ export class HaFilterLabels extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value.toLowerCase();
private _handleSearchChange(ev: InputEvent) {
const value = (ev.target as HaInputSearch).value ?? "";
this._filter = value.toLowerCase();
}
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
@@ -251,17 +248,13 @@ export class HaFilterLabels extends LitElement {
.warning {
color: var(--error-color);
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
search-input-outlined {
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
@@ -142,6 +142,19 @@ export const computeInitialHaFormData = (
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
+27 -5
View File
@@ -1,18 +1,44 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { computeCssColor } from "../common/color/compute-color";
import { getContrastedColorHex } from "../common/color/rgb";
import { uid } from "../common/util/uid";
import "./ha-tooltip";
/**
* Returns CSS styles for a label's background & icon/text
* @param color Label color defined in HEX format
* @returns CSS styles
*/
export const getLabelColorStyle = (labelColor: string | undefined | null) => {
const color = labelColor ? computeCssColor(labelColor) : undefined;
return color
? `--ha-label-background-color: ${color};
--primary-text-color: ${getContrastedColorHex(labelColor!)};`
: `--ha-label-background-color: rgba(var(--rgb-primary-text-color), 0.15);`;
};
@customElement("ha-label")
class HaLabel extends LitElement {
@property({ type: Boolean, reflect: true }) dense = false;
@property()
public color?: string;
@property({ attribute: "description" })
public description?: string;
private _elementId = "label-" + uid();
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!changedProps.has("color")) {
return;
}
this.style.cssText = getLabelColorStyle(this.color);
}
protected render(): TemplateResult {
return html`
<ha-tooltip
@@ -36,10 +62,6 @@ class HaLabel extends LitElement {
:host {
--ha-label-text-color: var(--primary-text-color);
--ha-label-icon-color: var(--primary-text-color);
--ha-label-background-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--ha-label-background-opacity: 1;
border: 1px solid var(--outline-color);
position: relative;
+11 -7
View File
@@ -6,7 +6,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { labelsContext } from "../data/context";
@@ -17,6 +16,7 @@ import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import { getLabelColorStyle } from "./ha-label";
import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker";
import "./ha-tooltip";
@@ -106,9 +106,14 @@ export class HaLabelsPicker extends LitElement {
labels?.find((label) => label.label_id === id) || {
label_id: id,
name: id,
color: "rgba(var(--rgb-primary-text-color), 0.15)",
}
)
.sort((a, b) => stringCompare(a?.name || "", b?.name || "", language))
.map((label) => ({
...label,
style: getLabelColorStyle(label.color),
}))
);
protected render(): TemplateResult {
@@ -135,9 +140,6 @@ export class HaLabelsPicker extends LitElement {
(label) => label?.label_id,
(label) => {
if (!label) return nothing;
const color = label.color
? computeCssColor(label.color)
: undefined;
const elementId = "label-" + label.label_id;
return html`
<ha-tooltip
@@ -154,7 +156,7 @@ export class HaLabelsPicker extends LitElement {
.disabled=${this.disabled}
.label=${label.name}
selected
style=${color ? `--color: ${color}` : ""}
style=${label.style}
>
${label.icon
? html`<ha-icon
@@ -239,8 +241,10 @@ export class HaLabelsPicker extends LitElement {
height: var(--ha-space-8);
}
ha-input-chip {
--md-input-chip-selected-container-color: var(--color, var(--grey-color));
--ha-input-chip-selected-container-opacity: 0.5;
--md-input-chip-selected-container-color: var(
--ha-label-background-color,
var(--grey-color)
);
--md-input-chip-selected-outline-width: 1px;
}
label {
-50
View File
@@ -1,50 +0,0 @@
import { OutlinedTextField } from "@material/web/textfield/internal/outlined-text-field";
import { styles } from "@material/web/textfield/internal/outlined-styles";
import { styles as sharedStyles } from "@material/web/textfield/internal/shared-styles";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
import "./ha-outlined-field";
@customElement("ha-outlined-text-field")
export class HaOutlinedTextField extends OutlinedTextField {
protected readonly fieldTag = literal`ha-outlined-field`;
static override styles = [
sharedStyles,
styles,
css`
:host {
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-primary: var(--primary-text-color);
--md-outlined-text-field-input-text-color: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-outlined-field-outline-color: var(--outline-color);
--md-outlined-field-focus-outline-color: var(--primary-color);
--md-outlined-field-hover-outline-color: var(--outline-hover-color);
}
:host([dense]) {
--md-outlined-field-top-space: 5.5px;
--md-outlined-field-bottom-space: 5.5px;
--md-outlined-field-container-shape-start-start: 10px;
--md-outlined-field-container-shape-start-end: 10px;
--md-outlined-field-container-shape-end-end: 10px;
--md-outlined-field-container-shape-end-start: 10px;
--md-outlined-field-focus-outline-width: 1px;
--md-outlined-field-with-leading-content-leading-space: 8px;
--md-outlined-field-with-trailing-content-trailing-space: 8px;
--md-outlined-field-content-space: 8px;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
.input {
font-family: var(--ha-font-family-body);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-text-field": HaOutlinedTextField;
}
}
+14 -28
View File
@@ -1,7 +1,7 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiClose, mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import { mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -30,8 +30,8 @@ import "./ha-combo-box-item";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
export const DEFAULT_SEARCH_KEYS: FuseWeightedKey[] = [
{
@@ -159,7 +159,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@state()
@consume({ context: localizeContext, subscribe: true })
@@ -226,19 +226,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
"Search | Add custom value")
: (this.localize?.("ui.common.search") ?? "Search"));
return html`<ha-textfield
.label=${searchLabel}
return html`<ha-input-search
appearance="outlined"
.placeholder=${searchLabel}
@blur=${this._resetSelectedItem}
@input=${this._filterChanged}
.iconTrailing=${this.clearable && !!this._search}
>
<ha-icon-button
@click=${this._clearSearch}
slot="trailingIcon"
.label=${this.localize?.("ui.common.clear") || "Clear"}
.path=${mdiClose}
></ha-icon-button>
</ha-textfield>
</ha-input-search>
${this._renderSectionButtons()}
${this.sections?.length
? html`
@@ -455,21 +449,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "index-selected", { index, newTab });
}
private _clearSearch = () => {
if (this._searchFieldElement) {
this._searchFieldElement.value = "";
this._searchFieldElement.dispatchEvent(new Event("input"));
}
};
private _fuseIndex = memoizeOne(
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
);
private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim();
private _filterChanged = (ev: InputEvent) => {
const textfield = ev.target as HaInputSearch;
const searchString = (textfield.value ?? "").trim();
this._search = searchString;
if (this.sections?.length) {
@@ -810,12 +797,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
--text-field-padding-end: 0;
}
ha-textfield {
ha-input-search {
padding: 0 var(--ha-space-3);
margin-bottom: var(--ha-space-3);
}
:host([mode="dialog"]) ha-textfield {
:host([mode="dialog"]) ha-input-search {
padding: 0 var(--ha-space-4);
}
@@ -929,7 +915,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
padding: var(--ha-space-1) var(--ha-space-4);
}
:host([mode="dialog"]) ha-textfield {
:host([mode="dialog"]) ha-input-search {
padding: 0 var(--ha-space-4);
}
+3 -11
View File
@@ -121,6 +121,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
css`
ha-combo-box-item[disabled] {
background-color: var(--ha-color-form-background-disabled);
--md-list-item-disabled-opacity: 0.5;
opacity: 0.5;
cursor: not-allowed;
}
ha-combo-box-item {
@@ -141,13 +143,6 @@ export class HaPickerField extends PickerMixin(LitElement) {
--md-focus-ring-duration: 0s;
}
/* Add Similar focus style as the text field */
ha-combo-box-item[disabled]:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
ha-combo-box-item:after {
display: block;
content: "";
@@ -158,10 +153,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
background-color: var(--ha-color-border-neutral-loud);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
+8 -8
View File
@@ -17,8 +17,8 @@ import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-spinner";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
prepareZXingModule({
overrides: {
@@ -64,7 +64,7 @@ class HaQrScanner extends LitElement {
@query("#canvas-container", true) private _canvasContainer?: HTMLDivElement;
@query("ha-textfield") private _manualInput?: HaTextField;
@query("ha-input") private _manualInput?: HaInput;
public disconnectedCallback(): void {
super.disconnectedCallback();
@@ -153,13 +153,13 @@ class HaQrScanner extends LitElement {
</ha-alert>
<p>${this.hass.localize("ui.components.qr-scanner.manual_input")}</p>
<div class="row">
<ha-textfield
<ha-input
.label=${this.hass.localize(
"ui.components.qr-scanner.enter_qr_code"
)}
@keyup=${this._manualKeyup}
@paste=${this._manualPaste}
></ha-textfield>
></ha-input>
<ha-button @click=${this._manualSubmit}>
${this.hass.localize("ui.common.submit")}
</ha-button>
@@ -242,7 +242,7 @@ class HaQrScanner extends LitElement {
private _manualKeyup(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._qrCodeScanned((ev.target as HaTextField).value);
this._qrCodeScanned((ev.target as HaInput).value ?? "");
}
}
@@ -254,7 +254,7 @@ class HaQrScanner extends LitElement {
}
private _manualSubmit() {
this._qrCodeScanned(this._manualInput!.value);
this._qrCodeScanned(this._manualInput!.value ?? "");
}
private _handleDropdownSelect(ev: HaDropdownSelectEvent) {
@@ -382,7 +382,7 @@ class HaQrScanner extends LitElement {
display: flex;
align-items: center;
}
ha-textfield {
ha-input {
flex: 1;
margin-right: 8px;
margin-inline-end: 8px;
+310
View File
@@ -0,0 +1,310 @@
export const RETRO_THEME = {
// Sharp corners
"ha-border-radius-sm": "0",
"ha-border-radius-md": "0",
"ha-border-radius-lg": "0",
"ha-border-radius-xl": "0",
"ha-border-radius-2xl": "0",
"ha-border-radius-3xl": "0",
"ha-border-radius-4xl": "0",
"ha-border-radius-5xl": "0",
"ha-border-radius-6xl": "0",
"ha-border-radius-pill": "0",
"ha-border-radius-circle": "0",
// Fonts
"ha-font-family-body":
"Tahoma, 'MS Sans Serif', 'Microsoft Sans Serif', Arial, sans-serif",
"ha-font-family-heading":
"Tahoma, 'MS Sans Serif', 'Microsoft Sans Serif', Arial, sans-serif",
"ha-font-family-code": "'Courier New', Courier, monospace",
"ha-font-family-longform":
"Tahoma, 'MS Sans Serif', 'Microsoft Sans Serif', Arial, sans-serif",
// No transparency
"ha-dialog-scrim-backdrop-filter": "none",
// Disable animations
"ha-animation-duration-fast": "1ms",
"ha-animation-duration-normal": "1ms",
"ha-animation-duration-slow": "1ms",
modes: {
light: {
// Base colors
"primary-color": "#000080",
"dark-primary-color": "#00006B",
"light-primary-color": "#4040C0",
"accent-color": "#000080",
"primary-text-color": "#000000",
"secondary-text-color": "#404040",
"text-primary-color": "#ffffff",
"text-light-primary-color": "#000000",
"disabled-text-color": "#808080",
// Backgrounds
"primary-background-color": "#C0C0C0",
"lovelace-background": "#008080",
"secondary-background-color": "#C0C0C0",
"card-background-color": "#C0C0C0",
"clear-background-color": "#C0C0C0",
// RGB values
"rgb-primary-color": "0, 0, 128",
"rgb-accent-color": "0, 0, 128",
"rgb-primary-text-color": "0, 0, 0",
"rgb-secondary-text-color": "64, 64, 64",
"rgb-text-primary-color": "255, 255, 255",
"rgb-card-background-color": "192, 192, 192",
// UI chrome
"divider-color": "#808080",
"outline-color": "#808080",
"outline-hover-color": "#404040",
"shadow-color": "rgba(0, 0, 0, 0.5)",
"scrollbar-thumb-color": "#808080",
"disabled-color": "#808080",
// Cards - retro bevel effect
"ha-card-border-width": "1px",
"ha-card-border-color": "#808080",
"ha-card-box-shadow": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
"ha-card-border-radius": "0",
// Dialogs
"ha-dialog-border-radius": "0",
"ha-dialog-surface-background": "#C0C0C0",
"dialog-box-shadow": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
// Box shadows - retro bevel
"ha-box-shadow-s": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
"ha-box-shadow-m": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
"ha-box-shadow-l": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
// Header
"app-header-background-color": "#000080",
"app-header-text-color": "#ffffff",
"app-header-border-bottom": "2px outset #C0C0C0",
// Sidebar
"sidebar-background-color": "#C0C0C0",
"sidebar-text-color": "#000000",
"sidebar-selected-text-color": "#ffffff",
"sidebar-selected-icon-color": "#000080",
"sidebar-icon-color": "#000000",
// Input
"input-fill-color": "#C0C0C0",
"input-disabled-fill-color": "#C0C0C0",
"input-ink-color": "#000000",
"input-label-ink-color": "#000000",
"input-disabled-ink-color": "#808080",
"input-idle-line-color": "#808080",
"input-hover-line-color": "#000000",
"input-disabled-line-color": "#808080",
"input-outlined-idle-border-color": "#808080",
"input-outlined-hover-border-color": "#000000",
"input-outlined-disabled-border-color": "#C0C0C0",
"input-dropdown-icon-color": "#000000",
// Status colors
"error-color": "#FF0000",
"warning-color": "#FF8000",
"success-color": "#008000",
"info-color": "#000080",
// State
"state-icon-color": "#000080",
"state-active-color": "#000080",
"state-inactive-color": "#808080",
// Data table
"data-table-border-width": "0",
// Primary scale
"ha-color-primary-05": "#00003A",
"ha-color-primary-10": "#000050",
"ha-color-primary-20": "#000066",
"ha-color-primary-30": "#00007A",
"ha-color-primary-40": "#000080",
"ha-color-primary-50": "#0000AA",
"ha-color-primary-60": "#4040C0",
"ha-color-primary-70": "#6060D0",
"ha-color-primary-80": "#8080E0",
"ha-color-primary-90": "#C8C8D8",
"ha-color-primary-95": "#D8D8E0",
// Neutral scale
"ha-color-neutral-05": "#000000",
"ha-color-neutral-10": "#2A2A2A",
"ha-color-neutral-20": "#404040",
"ha-color-neutral-30": "#606060",
"ha-color-neutral-40": "#707070",
"ha-color-neutral-50": "#808080",
"ha-color-neutral-60": "#909090",
"ha-color-neutral-70": "#A0A0A0",
"ha-color-neutral-80": "#B0B0B0",
"ha-color-neutral-90": "#C8C8C8",
"ha-color-neutral-95": "#D0D0D0",
// Codemirror
"codemirror-keyword": "#000080",
"codemirror-operator": "#000000",
"codemirror-variable": "#008080",
"codemirror-variable-2": "#000080",
"codemirror-variable-3": "#808000",
"codemirror-builtin": "#800080",
"codemirror-atom": "#008080",
"codemirror-number": "#FF0000",
"codemirror-def": "#000080",
"codemirror-string": "#008000",
"codemirror-string-2": "#808000",
"codemirror-comment": "#808080",
"codemirror-tag": "#800000",
"codemirror-meta": "#000080",
"codemirror-attribute": "#FF0000",
"codemirror-property": "#000080",
"codemirror-qualifier": "#808000",
"codemirror-type": "#000080",
},
dark: {
// Base colors
"primary-color": "#4040C0",
"dark-primary-color": "#000080",
"light-primary-color": "#6060D0",
"accent-color": "#4040C0",
"primary-text-color": "#C0C0C0",
"secondary-text-color": "#A0A0A0",
"text-primary-color": "#ffffff",
"text-light-primary-color": "#C0C0C0",
"disabled-text-color": "#606060",
// Backgrounds
"primary-background-color": "#2A2A2A",
"lovelace-background": "#003030",
"secondary-background-color": "#2A2A2A",
"card-background-color": "#3A3A3A",
"clear-background-color": "#2A2A2A",
// RGB values
"rgb-primary-color": "64, 64, 192",
"rgb-accent-color": "64, 64, 192",
"rgb-primary-text-color": "192, 192, 192",
"rgb-secondary-text-color": "160, 160, 160",
"rgb-text-primary-color": "255, 255, 255",
"rgb-card-background-color": "58, 58, 58",
// UI chrome
"divider-color": "#606060",
"outline-color": "#606060",
"outline-hover-color": "#808080",
"shadow-color": "rgba(0, 0, 0, 0.7)",
"scrollbar-thumb-color": "#606060",
"disabled-color": "#606060",
// Cards - retro bevel effect
"ha-card-border-width": "1px",
"ha-card-border-color": "#606060",
"ha-card-box-shadow": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
"ha-card-border-radius": "0",
// Dialogs
"ha-dialog-border-radius": "0",
"ha-dialog-surface-background": "#3A3A3A",
"dialog-box-shadow": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
// Box shadows - retro bevel
"ha-box-shadow-s": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
"ha-box-shadow-m": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
"ha-box-shadow-l": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
// Header
"app-header-background-color": "#000060",
"app-header-text-color": "#ffffff",
"app-header-border-bottom": "2px outset #3A3A3A",
// Sidebar
"sidebar-background-color": "#2A2A2A",
"sidebar-text-color": "#C0C0C0",
"sidebar-selected-text-color": "#ffffff",
"sidebar-selected-icon-color": "#4040C0",
"sidebar-icon-color": "#A0A0A0",
// Input
"input-fill-color": "#3A3A3A",
"input-disabled-fill-color": "#3A3A3A",
"input-ink-color": "#C0C0C0",
"input-label-ink-color": "#A0A0A0",
"input-disabled-ink-color": "#606060",
"input-idle-line-color": "#606060",
"input-hover-line-color": "#808080",
"input-disabled-line-color": "#404040",
"input-outlined-idle-border-color": "#606060",
"input-outlined-hover-border-color": "#808080",
"input-outlined-disabled-border-color": "#404040",
"input-dropdown-icon-color": "#A0A0A0",
// Status colors
"error-color": "#FF4040",
"warning-color": "#FFA040",
"success-color": "#40C040",
"info-color": "#4040C0",
// State
"state-icon-color": "#4040C0",
"state-active-color": "#4040C0",
"state-inactive-color": "#606060",
// Data table
"data-table-border-width": "0",
// Primary scale
"ha-color-primary-05": "#00002A",
"ha-color-primary-10": "#000040",
"ha-color-primary-20": "#000060",
"ha-color-primary-30": "#000080",
"ha-color-primary-40": "#4040C0",
"ha-color-primary-50": "#6060D0",
"ha-color-primary-60": "#8080E0",
"ha-color-primary-70": "#A0A0F0",
"ha-color-primary-80": "#C0C0FF",
"ha-color-primary-90": "#3A3A58",
"ha-color-primary-95": "#303048",
// Neutral scale
"ha-color-neutral-05": "#1A1A1A",
"ha-color-neutral-10": "#2A2A2A",
"ha-color-neutral-20": "#3A3A3A",
"ha-color-neutral-30": "#4A4A4A",
"ha-color-neutral-40": "#606060",
"ha-color-neutral-50": "#707070",
"ha-color-neutral-60": "#808080",
"ha-color-neutral-70": "#909090",
"ha-color-neutral-80": "#A0A0A0",
"ha-color-neutral-90": "#C0C0C0",
"ha-color-neutral-95": "#D0D0D0",
// Codemirror
"codemirror-keyword": "#8080E0",
"codemirror-operator": "#C0C0C0",
"codemirror-variable": "#40C0C0",
"codemirror-variable-2": "#8080E0",
"codemirror-variable-3": "#C0C040",
"codemirror-builtin": "#C040C0",
"codemirror-atom": "#40C0C0",
"codemirror-number": "#FF6060",
"codemirror-def": "#8080E0",
"codemirror-string": "#40C040",
"codemirror-string-2": "#C0C040",
"codemirror-comment": "#808080",
"codemirror-tag": "#C04040",
"codemirror-meta": "#8080E0",
"codemirror-attribute": "#FF6060",
"codemirror-property": "#8080E0",
"codemirror-qualifier": "#C0C040",
"codemirror-type": "#8080E0",
"map-filter":
"invert(0.9) hue-rotate(170deg) brightness(1.5) contrast(1.2) saturate(0.3)",
},
},
};
+683
View File
@@ -0,0 +1,683 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
applyThemesOnElement,
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import type { LocalizeKeys } from "../common/translations/localize";
import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import type { HomeAssistant } from "../types";
import { RETRO_THEME } from "./ha-retro-theme";
const TIP_COUNT = 25;
type CasitaExpression =
| "hi"
| "ok-nabu"
| "heart"
| "sleep"
| "great-job"
| "error";
const STORAGE_KEY = "retro-position";
const DRAG_THRESHOLD = 5;
const BUBBLE_TIMEOUT = 8000;
const SLEEP_TIMEOUT = 30000;
const BSOD_CLICK_COUNT = 5;
const BSOD_CLICK_TIMEOUT = 3000;
const BSOD_DISMISS_DELAY = 500;
@customElement("ha-retro")
export class HaRetro extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _enabled = false;
public hassSubscribe() {
return [
subscribeLabFeature(
this.hass!.connection,
"frontend",
"retro",
(feature) => {
this._enabled = feature.enabled;
}
),
];
}
@state() private _casitaVisible = true;
@state() private _showBubble = false;
@state() private _bubbleText = "";
@state() private _expression: CasitaExpression = "hi";
@state() private _position: { x: number; y: number } | null = null;
@state() private _showBsod = false;
private _clickCount = 0;
private _clickTimer?: ReturnType<typeof setTimeout>;
private _dragging = false;
private _dragStartX = 0;
private _dragStartY = 0;
private _dragOffsetX = 0;
private _dragOffsetY = 0;
private _dragMoved = false;
private _bubbleTimer?: ReturnType<typeof setTimeout>;
private _sleepTimer?: ReturnType<typeof setTimeout>;
private _boundPointerMove = this._onPointerMove.bind(this);
private _boundPointerUp = this._onPointerUp.bind(this);
private _themeApplied = false;
private _isApplyingTheme = false;
private _themeObserver?: MutationObserver;
connectedCallback(): void {
super.connectedCallback();
this._loadPosition();
this._resetSleepTimer();
this._applyRetroTheme();
this._startThemeObserver();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._clearTimers();
this._stopThemeObserver();
this._revertTheme();
document.removeEventListener("pointermove", this._boundPointerMove);
document.removeEventListener("pointerup", this._boundPointerUp);
document.removeEventListener("keydown", this._boundDismissBsod);
}
protected willUpdate(changedProps: Map<string, unknown>): void {
if (changedProps.has("_enabled")) {
if (this._enabled) {
this.hass!.loadFragmentTranslation("retro");
this._applyRetroTheme();
this._startThemeObserver();
} else {
this._stopThemeObserver();
this._revertTheme();
}
}
if (changedProps.has("hass") && this._enabled) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
// Re-apply if darkMode changed
if (oldHass && oldHass.themes.darkMode !== this.hass!.themes.darkMode) {
this._themeApplied = false;
this._applyRetroTheme();
}
}
}
private _startThemeObserver(): void {
if (this._themeObserver) return;
this._themeObserver = new MutationObserver(() => {
if (this._isApplyingTheme || !this._enabled || !this.hass) return;
// Check if our theme was overwritten by the themes mixin
const el = document.documentElement as HTMLElement & {
__themes?: { cacheKey?: string };
};
if (!el.__themes?.cacheKey?.startsWith("Retro")) {
this._themeApplied = false;
this._applyRetroTheme();
}
});
this._themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style"],
});
}
private _stopThemeObserver(): void {
this._themeObserver?.disconnect();
this._themeObserver = undefined;
}
private _applyRetroTheme(): void {
if (!this.hass || this._themeApplied) return;
this._isApplyingTheme = true;
const themes = {
...this.hass.themes,
themes: {
...this.hass.themes.themes,
Retro: RETRO_THEME,
},
};
invalidateThemeCache();
applyThemesOnElement(
document.documentElement,
themes,
"Retro",
{ dark: this.hass.themes.darkMode },
true
);
this._themeApplied = true;
this._isApplyingTheme = false;
}
private _revertTheme(): void {
if (!this.hass || !this._themeApplied) return;
this._isApplyingTheme = true;
invalidateThemeCache();
applyThemesOnElement(
document.documentElement,
this.hass.themes,
this.hass.selectedTheme?.theme || "default",
{
dark: this.hass.themes.darkMode,
primaryColor: this.hass.selectedTheme?.primaryColor,
accentColor: this.hass.selectedTheme?.accentColor,
},
true
);
this._themeApplied = false;
this._isApplyingTheme = false;
}
private _loadPosition(): void {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const pos = JSON.parse(stored);
if (typeof pos.x === "number" && typeof pos.y === "number") {
this._position = this._clampPosition(pos.x, pos.y);
}
}
} catch {
// Ignore invalid stored position
}
}
private _savePosition(): void {
if (this._position) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._position));
} catch {
// Ignore storage errors
}
}
}
private _clampPosition(x: number, y: number): { x: number; y: number } {
const size = 80;
return {
x: Math.max(0, Math.min(window.innerWidth - size, x)),
y: Math.max(0, Math.min(window.innerHeight - size, y)),
};
}
private _onPointerDown(ev: PointerEvent): void {
if (ev.button !== 0 || this._showBsod) return;
this._dragging = true;
this._dragMoved = false;
this._dragStartX = ev.clientX;
this._dragStartY = ev.clientY;
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
this._dragOffsetX = ev.clientX - rect.left;
this._dragOffsetY = ev.clientY - rect.top;
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
document.addEventListener("pointermove", this._boundPointerMove);
document.addEventListener("pointerup", this._boundPointerUp);
ev.preventDefault();
}
private _onPointerMove(ev: PointerEvent): void {
if (!this._dragging) return;
const dx = ev.clientX - this._dragStartX;
const dy = ev.clientY - this._dragStartY;
if (!this._dragMoved && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
return;
}
this._dragMoved = true;
const x = ev.clientX - this._dragOffsetX;
const y = ev.clientY - this._dragOffsetY;
this._position = this._clampPosition(x, y);
}
private _onPointerUp(ev: PointerEvent): void {
document.removeEventListener("pointermove", this._boundPointerMove);
document.removeEventListener("pointerup", this._boundPointerUp);
this._dragging = false;
if (this._dragMoved) {
this._savePosition();
} else {
this._toggleBubble();
}
ev.preventDefault();
}
private _stopPropagation(ev: Event): void {
ev.stopPropagation();
}
private _dismiss(ev: Event): void {
ev.stopPropagation();
this._casitaVisible = false;
this._clearTimers();
}
private _toggleBubble(): void {
this._clickCount++;
if (this._clickTimer) {
clearTimeout(this._clickTimer);
}
this._clickTimer = setTimeout(() => {
this._clickCount = 0;
}, BSOD_CLICK_TIMEOUT);
if (this._clickCount >= BSOD_CLICK_COUNT) {
this._clickCount = 0;
this._triggerBsod();
return;
}
if (this._showBubble) {
this._hideBubble();
} else {
this._showTip();
}
}
private _boundDismissBsod = this._dismissBsodOnKey.bind(this);
private _bsodReadyToDismiss = false;
private _triggerBsod(): void {
this._hideBubble();
this._showBsod = true;
this._bsodReadyToDismiss = false;
this._expression = "error";
// Delay enabling dismiss so the rapid clicks that triggered the BSOD don't immediately close it
setTimeout(() => {
this._bsodReadyToDismiss = true;
document.addEventListener("keydown", this._boundDismissBsod);
}, BSOD_DISMISS_DELAY);
}
private _dismissBsod(): void {
if (!this._bsodReadyToDismiss) return;
this._showBsod = false;
this._expression = "hi";
this._resetSleepTimer();
document.removeEventListener("keydown", this._boundDismissBsod);
}
private _dismissBsodOnKey(): void {
this._dismissBsod();
}
private _showTip(): void {
const tipIndex = Math.floor(Math.random() * TIP_COUNT) + 1;
this._bubbleText = this.hass!.localize(
`ui.panel.retro.tip_${tipIndex}` as LocalizeKeys
);
this._showBubble = true;
this._expression = "ok-nabu";
this._resetSleepTimer();
if (this._bubbleTimer) {
clearTimeout(this._bubbleTimer);
}
this._bubbleTimer = setTimeout(() => {
this._hideBubble();
}, BUBBLE_TIMEOUT);
}
private _hideBubble(): void {
this._showBubble = false;
this._expression = "hi";
this._resetSleepTimer();
if (this._bubbleTimer) {
clearTimeout(this._bubbleTimer);
this._bubbleTimer = undefined;
}
}
private _closeBubble(ev: Event): void {
ev.stopPropagation();
this._hideBubble();
}
private _resetSleepTimer(): void {
if (this._sleepTimer) {
clearTimeout(this._sleepTimer);
}
this._sleepTimer = setTimeout(() => {
if (!this._showBubble) {
this._expression = "sleep";
}
}, SLEEP_TIMEOUT);
}
private _clearTimers(): void {
if (this._bubbleTimer) {
clearTimeout(this._bubbleTimer);
this._bubbleTimer = undefined;
}
if (this._sleepTimer) {
clearTimeout(this._sleepTimer);
this._sleepTimer = undefined;
}
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = undefined;
}
}
protected render() {
if (!this._enabled || !this._casitaVisible) {
return nothing;
}
const size = 80;
const posStyle = this._position
? `left: ${this._position.x}px; top: ${this._position.y}px;`
: `right: 16px; bottom: 16px;`;
return html`
${this._showBsod
? html`
<div class="bsod" @click=${this._dismissBsod}>
<div class="bsod-content">
<h1 class="bsod-title">
${this.hass!.localize("ui.panel.retro.bsod_title")}
</h1>
<p>${this.hass!.localize("ui.panel.retro.bsod_error")}</p>
<p>
* ${this.hass!.localize("ui.panel.retro.bsod_line_1")}<br />
* ${this.hass!.localize("ui.panel.retro.bsod_line_2")}
</p>
<p class="bsod-prompt">
${this.hass!.localize("ui.panel.retro.bsod_continue")}
<span class="bsod-cursor">_</span>
</p>
</div>
</div>
`
: nothing}
<div
class="casita-container ${this._dragging ? "dragging" : ""}"
style="width: ${size}px; ${posStyle}"
aria-hidden="true"
@pointerdown=${this._onPointerDown}
>
${this._showBubble
? html`
<div class="speech-bubble">
<span class="bubble-text">${this._bubbleText}</span>
<button
class="bubble-close"
@pointerdown=${this._stopPropagation}
@click=${this._closeBubble}
>
</button>
<button
class="bubble-dismiss"
@pointerdown=${this._stopPropagation}
@click=${this._dismiss}
>
${this.hass!.localize("ui.panel.retro.dismiss")}
</button>
<div class="bubble-arrow"></div>
</div>
`
: nothing}
<img
class="casita-image"
src="/static/images/voice-assistant/${this._expression}.png"
alt="Casita"
draggable="false"
/>
</div>
`;
}
static readonly styles = css`
:host {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
z-index: 9999;
}
.casita-container {
position: fixed;
pointer-events: auto;
cursor: grab;
user-select: none;
touch-action: none;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
}
.casita-container.dragging {
cursor: grabbing;
}
.casita-image {
width: 100%;
height: auto;
animation: bob 3s ease-in-out infinite;
pointer-events: none;
}
.dragging .casita-image {
animation: none;
}
.speech-bubble {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
background: #ffffe1;
color: #000000;
border-radius: 12px;
border: 2px solid #000000;
padding: 12px 28px 12px 12px;
font-family: Tahoma, "MS Sans Serif", Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
width: 300px;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
animation: bubble-in 200ms ease-out;
pointer-events: auto;
}
.bubble-close {
position: absolute;
top: 4px;
right: 4px;
background: none;
border: none;
cursor: pointer;
color: #000000;
font-size: 14px;
padding: 2px 6px;
line-height: 1;
border-radius: 50%;
}
.bubble-close:hover {
background: #e0e0c0;
}
.bubble-dismiss {
display: block;
margin-top: 8px;
background: none;
border: none;
cursor: pointer;
color: #808080;
font-family: Tahoma, "MS Sans Serif", Arial, sans-serif;
font-size: 12px;
padding: 0;
text-decoration: underline;
}
.bubble-dismiss:hover {
color: #000000;
}
.bubble-arrow {
position: absolute;
bottom: -8px;
right: 32px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #ffffe1;
}
@keyframes bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes bubble-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bsod {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0000aa;
color: #ffffff;
font-family: "Lucida Console", "Courier New", monospace;
font-size: 16px;
line-height: 1.6;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
pointer-events: auto;
animation: bsod-in 100ms ease-out;
}
.bsod-content {
max-width: 700px;
padding: 32px;
text-align: left;
}
.bsod-title {
display: inline-block;
background: #aaaaaa;
color: #0000aa;
padding: 2px 12px;
font-size: 18px;
font-weight: normal;
margin: 0 0 24px;
}
.bsod-content p {
margin: 16px 0;
}
.bsod-prompt {
margin-top: 32px;
}
.bsod-cursor {
animation: blink 1s step-end infinite;
}
@keyframes bsod-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes blink {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.casita-image {
animation: none;
}
.speech-bubble {
animation: none;
}
.bsod {
animation: none;
}
.bsod-cursor {
animation: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-retro": HaRetro;
}
}
@@ -1,21 +1,32 @@
import memoizeOne from "memoize-one";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
mdiArrowCollapseVertical,
mdiArrowExpandVertical,
mdiGreaterThan,
mdiLessThan,
} from "@mdi/js";
import { mdiChartBellCurveCumulative } from "@mdi/js";
import { fireEvent } from "../../common/dom/fire_event";
import type { NumericThresholdSelector } from "../../data/selector";
import type {
NumericThresholdSelector,
ThresholdMode,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-button-toggle-group";
import "../ha-input-helper-text";
import "../ha-select";
import "./ha-selector";
type ThresholdType = "above" | "below" | "between" | "outside";
const iconThresholdAbove =
"M 2 2 L 2 22 L 22 22 L 22 20 L 4 20 L 4 2 L 2 2 z M 17.976562 6 C 17.965863 6.00017 17.951347 6.0014331 17.935547 6.0019531 C 17.903847 6.0030031 17.862047 6.0043225 17.810547 6.0078125 C 17.707247 6.0148425 17.564772 6.0273144 17.388672 6.0527344 C 17.036572 6.1035743 16.54829 6.2035746 15.962891 6.3964844 C 14.788292 6.783584 13.232027 7.5444846 11.611328 9.0332031 C 10.753918 9.820771 9.8854345 10.808987 9.0449219 12.042969 C 7.881634 12.257047 7 13.274809 7 14.5 C 7 15.880699 8.1192914 17 9.5 17 C 10.880699 17 12 15.880699 12 14.5 C 12 13.732663 11.653544 13.046487 11.109375 12.587891 C 11.732682 11.74814 12.359503 11.061942 12.964844 10.505859 C 14.359842 9.2245207 15.662945 8.6023047 16.589844 8.296875 C 17.054643 8.1437252 17.426428 8.0689231 17.673828 8.0332031 C 17.797428 8.0153531 17.891466 8.0076962 17.947266 8.0039062 C 17.974966 8.0020263 17.992753 8.0003 18.001953 8 L 17.998047 6 L 17.976562 6 z";
const iconThresholdBelow =
"M 2 2 L 2 22 L 22 22 L 22 20 L 4 20 L 4 2 L 2 2 z M 9.5 7 C 8.1192914 7 7 8.1192914 7 9.5 C 7 10.880699 8.1192914 12 9.5 12 C 9.598408 12 9.6955741 11.993483 9.7910156 11.982422 C 10.39444 12.754246 11.005767 13.410563 11.611328 13.966797 C 13.232027 15.455495 14.788292 16.216416 15.962891 16.603516 C 16.54829 16.796415 17.036572 16.896366 17.388672 16.947266 C 17.564772 16.972666 17.707247 16.985188 17.810547 16.992188 C 17.862047 16.995687 17.903847 16.997047 17.935547 16.998047 C 17.951347 16.998547 17.965863 16.9998 17.976562 17 L 17.998047 17 L 18.001953 15 C 17.992753 14.9997 17.974966 14.999947 17.947266 14.998047 C 17.891466 14.994247 17.797428 14.984597 17.673828 14.966797 C 17.426428 14.931097 17.054643 14.856325 16.589844 14.703125 C 15.662945 14.397725 14.359842 13.775439 12.964844 12.494141 C 12.496227 12.063656 12.015935 11.551532 11.533203 10.955078 C 11.826929 10.545261 12 10.042666 12 9.5 C 12 8.1192914 10.880699 7 9.5 7 z";
const iconThresholdBetween =
"M 2 2 L 2 22 L 22 22 L 22 20 L 4 20 L 4 2 L 2 2 z M 16.5 4 C 15.119301 4 14 5.1192914 14 6.5 C 14 6.8572837 14.075904 7.196497 14.210938 7.5039062 C 13.503071 8.3427071 12.800578 9.3300361 12.130859 10.501953 C 11.718781 11.223082 11.287475 11.849823 10.845703 12.394531 C 10.457136 12.145771 9.9956073 12 9.5 12 C 8.1192914 12 7 13.119301 7 14.5 C 7 15.880699 8.1192914 17 9.5 17 C 10.880699 17 12 15.880699 12 14.5 C 12 14.38201 11.990422 14.26598 11.974609 14.152344 C 12.636605 13.409426 13.276156 12.531884 13.869141 11.494141 C 14.462491 10.455789 15.073208 9.5905169 15.681641 8.8613281 C 15.938115 8.9501682 16.213303 9 16.5 9 C 17.880699 9 19 7.8807086 19 6.5 C 19 5.1192914 17.880699 4 16.5 4 z";
const iconThresholdOutside =
"M 2 2 L 2 22 L 22 22 L 22 20 L 4 20 L 4 19.046875 C 4.226574 19.041905 4.4812768 19.028419 4.7597656 19 C 5.8832145 18.8854 7.4011147 18.537974 9.0019531 17.609375 L 8.8847656 17.408203 C 9.320466 17.777433 9.8841605 18 10.5 18 C 11.880699 18 13 16.880699 13 15.5 C 13 14.119301 11.880699 13 10.5 13 C 9.1192914 13 8 14.119301 8 15.5 C 8 15.654727 8.0141099 15.806171 8.0410156 15.953125 L 7.9980469 15.876953 C 6.6882482 16.636752 5.4555097 16.918066 4.5566406 17.009766 C 4.3512557 17.030705 4.166436 17.040275 4 17.044922 L 4 2 L 2 2 z M 21.976562 4 C 21.965863 4.00017 21.951347 4.0014331 21.935547 4.0019531 C 21.903847 4.0030031 21.862047 4.0043225 21.810547 4.0078125 C 21.707247 4.0148425 21.564772 4.0273144 21.388672 4.0527344 C 21.036572 4.1035743 20.54829 4.2035846 19.962891 4.3964844 C 19.34193 4.6011277 18.613343 4.9149715 17.826172 5.3808594 C 17.441793 5.1398775 16.987134 5 16.5 5 C 15.119301 5 14 6.1192914 14 7.5 C 14 8.8807086 15.119301 10 16.5 10 C 17.880699 10 19 8.8807086 19 7.5 C 19 7.3403872 18.983669 7.1845035 18.955078 7.0332031 C 19.569666 6.6795942 20.126994 6.4493921 20.589844 6.296875 C 21.054643 6.1437252 21.426428 6.0689231 21.673828 6.0332031 C 21.797428 6.0153531 21.891466 6.0076962 21.947266 6.0039062 C 21.974966 6.0020263 21.992753 6.0003 22.001953 6 L 21.998047 4 L 21.976562 4 z";
type ThresholdType = "above" | "below" | "between" | "outside" | "any";
interface ThresholdValueEntry {
active_choice?: string;
@@ -31,6 +42,12 @@ interface NumericThresholdValue {
value_max?: ThresholdValueEntry;
}
const DEFAULT_TYPE: Record<ThresholdMode, ThresholdType> = {
crossed: "above",
changed: "any",
is: "above",
};
@customElement("ha-selector-numeric_threshold")
export class HaNumericThresholdSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -49,9 +66,26 @@ export class HaNumericThresholdSelector extends LitElement {
@state() private _type?: ThresholdType;
private _getMode(): ThresholdMode {
return this.selector.numeric_threshold?.mode ?? "crossed";
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("value")) {
this._type = this.value?.type || "above";
if (changedProperties.has("value") || changedProperties.has("selector")) {
const mode = this._getMode();
this._type = this.value?.type || DEFAULT_TYPE[mode];
}
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (
(changedProperties.has("value") || changedProperties.has("selector")) &&
!this.value
) {
const mode = this._getMode();
const type = DEFAULT_TYPE[mode];
fireEvent(this, "value-changed", { value: { type } });
}
}
@@ -83,41 +117,13 @@ export class HaNumericThresholdSelector extends LitElement {
}
protected render() {
const type = this._type || "above";
const mode = this._getMode();
const type = this._type || DEFAULT_TYPE[mode];
const showSingleValue = type === "above" || type === "below";
const showRangeValues = type === "between" || type === "outside";
const unitOptions = this._getUnitOptions();
const typeOptions = [
{
value: "above",
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.above"
),
iconPath: mdiGreaterThan,
},
{
value: "below",
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.below"
),
iconPath: mdiLessThan,
},
{
value: "between",
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.in_range"
),
iconPath: mdiArrowCollapseVertical,
},
{
value: "outside",
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.outside_range"
),
iconPath: mdiArrowExpandVertical,
},
];
const typeOptions = this._buildTypeOptions(this.hass.localize, mode);
const choiceToggleButtons = [
{
@@ -134,6 +140,16 @@ export class HaNumericThresholdSelector extends LitElement {
},
];
// Value-row label for single-value types (above/below).
const singleValueLabel = this.hass.localize(
`ui.components.selectors.numeric_threshold.${mode}.${type as "above" | "below"}`
);
// Determine which type-select label to use per mode
const typeSelectLabel = this.hass.localize(
`ui.components.selectors.numeric_threshold.${mode}.type`
);
return html`
<div class="container">
${this.label
@@ -141,9 +157,7 @@ export class HaNumericThresholdSelector extends LitElement {
: nothing}
<div class="inputs">
<ha-select
.label=${this.hass.localize(
"ui.components.selectors.numeric_threshold.type"
)}
.label=${typeSelectLabel}
.value=${type}
.options=${typeOptions}
.disabled=${this.disabled}
@@ -152,11 +166,7 @@ export class HaNumericThresholdSelector extends LitElement {
${showSingleValue
? this._renderValueRow(
this.hass.localize(
type === "above"
? "ui.components.selectors.numeric_threshold.above"
: "ui.components.selectors.numeric_threshold.below"
),
singleValueLabel,
this.value?.value,
this._valueChanged,
this._valueChoiceChanged,
@@ -199,6 +209,40 @@ export class HaNumericThresholdSelector extends LitElement {
`;
}
private _buildTypeOptions = memoizeOne(
(localize: HomeAssistant["localize"], mode: ThresholdMode) => {
const baseOptions = (
[
{ value: "above", iconPath: iconThresholdAbove },
{ value: "below", iconPath: iconThresholdBelow },
{ value: "between", iconPath: iconThresholdBetween },
{ value: "outside", iconPath: iconThresholdOutside },
] as const
).map(({ value, iconPath }) => ({
value,
iconPath,
label: localize(
`ui.components.selectors.numeric_threshold.${mode}.${value}`
),
}));
if (mode !== "changed") {
return baseOptions;
}
return [
{
value: "any",
iconPath: mdiChartBellCurveCumulative,
label: localize(
"ui.components.selectors.numeric_threshold.changed.any"
),
},
...baseOptions,
];
}
);
private _renderUnitSelect(
entry: ThresholdValueEntry | undefined,
handler: (ev: CustomEvent) => void,
@@ -243,7 +287,9 @@ export class HaNumericThresholdSelector extends LitElement {
const numberSelector = {
number: {
...this.selector.numeric_threshold?.number,
...(effectiveUnit ? { unit_of_measurement: effectiveUnit } : {}),
...(!showUnit && effectiveUnit
? { unit_of_measurement: effectiveUnit }
: {}),
},
};
const entitySelector = {
@@ -255,9 +301,11 @@ export class HaNumericThresholdSelector extends LitElement {
return html`
<div class="value-row">
<div class="value-header">
<span class="value-label"
>${rowLabel}${this.required ? "*" : ""}</span
>
${rowLabel
? html`<span class="value-label"
>${rowLabel}${this.required ? "*" : ""}</span
>`
: nothing}
<ha-button-toggle-group
size="small"
.buttons=${choiceToggleButtons}
@@ -302,6 +350,7 @@ export class HaNumericThresholdSelector extends LitElement {
newValue.value_min = this.value?.value_min ?? this.value?.value;
newValue.value_max = this.value?.value_max;
}
// "any" type has no value fields
fireEvent(this, "value-changed", { value: newValue });
}
@@ -428,6 +477,7 @@ export class HaNumericThresholdSelector extends LitElement {
.inputs,
.value-row {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
@@ -447,7 +497,7 @@ export class HaNumericThresholdSelector extends LitElement {
.value-inputs {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
align-items: flex-start;
}
.value-selector {
+17
View File
@@ -21,6 +21,8 @@ export class HaTimeInput extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "auto-validate", type: Boolean }) autoValidate = false;
@property({ type: Boolean, attribute: "enable-second" })
public enableSecond = false;
@@ -71,6 +73,7 @@ export class HaTimeInput extends LitElement {
.clearable=${this.clearable && this.value !== undefined}
.helper=${this.helper}
.placeholderLabels=${this.placeholderLabels}
.autoValidate=${this.autoValidate}
day-label="dd"
hour-label="hh"
min-label="mm"
@@ -86,6 +89,7 @@ export class HaTimeInput extends LitElement {
const useAMPM = useAmPm(this.locale);
let value: string | undefined;
let updateHours = 0;
// An undefined eventValue means the time selector is being cleared,
// the `value` variable will (intentionally) be left undefined.
@@ -97,6 +101,8 @@ export class HaTimeInput extends LitElement {
) {
let hours = eventValue.hours || 0;
if (eventValue && useAMPM) {
updateHours =
hours >= 12 && hours < 24 ? hours - 12 : hours === 0 ? 12 : 0;
if (eventValue.amPm === "PM" && hours < 12) {
hours += 12;
}
@@ -115,6 +121,17 @@ export class HaTimeInput extends LitElement {
}`;
}
if (updateHours) {
// If the user entered a 24hr time in a 12hr input, we need to refresh the
// input to ensure it resets back to the 12hr equivalent.
this.updateComplete.then(() => {
const input = this._input;
if (input) {
input.hours = updateHours;
}
});
}
if (value === this.value) {
return;
}
+194 -85
View File
@@ -1,7 +1,16 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import {
customElement,
property,
query,
queryAssignedElements,
state,
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import { nextRender } from "../common/util/render-status";
export type ToastCloseReason =
| "dismiss"
@@ -19,23 +28,103 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@query("wa-popover")
private _popover?: WaPopover;
@query(".toast")
private _toast?: HTMLDivElement;
@queryAssignedElements({ slot: "action", flatten: true })
private _actionElements?: Element[];
@queryAssignedElements({ slot: "dismiss", flatten: true })
private _dismissElements?: Element[];
@state() private _active = false;
@state() private _visible = false;
private _dismissTimer?: ReturnType<typeof setTimeout>;
private _closeReason: ToastCloseReason = "programmatic";
private _transitionId = 0;
public disconnectedCallback(): void {
clearTimeout(this._dismissTimer);
this._transitionId += 1;
super.disconnectedCallback();
}
public async show(): Promise<void> {
await this.updateComplete;
await this._popover?.show();
clearTimeout(this._dismissTimer);
if (this._active && this._visible) {
this._setDismissTimer();
return;
}
const transitionId = ++this._transitionId;
this._active = true;
await this.updateComplete;
if (transitionId !== this._transitionId) {
return;
}
this._showToastPopover();
await nextRender();
if (transitionId !== this._transitionId) {
return;
}
this._visible = true;
await this.updateComplete;
await this._waitForTransitionEnd();
if (transitionId !== this._transitionId) {
return;
}
this._setDismissTimer();
}
public async hide(reason: ToastCloseReason = "programmatic"): Promise<void> {
clearTimeout(this._dismissTimer);
this._closeReason = reason;
if (!this._active) {
return;
}
const transitionId = ++this._transitionId;
const wasVisible = this._visible;
this._visible = false;
await this.updateComplete;
if (wasVisible) {
await this._waitForTransitionEnd();
}
if (transitionId !== this._transitionId) {
return;
}
this._hideToastPopover();
this._active = false;
await this.updateComplete;
fireEvent(this, "toast-closed", {
reason: this._closeReason,
});
this._closeReason = "programmatic";
}
public close(reason: ToastCloseReason = "programmatic"): void {
this.hide(reason);
}
private _setDismissTimer() {
if (this.timeoutMs > 0) {
this._dismissTimer = setTimeout(() => {
this.hide("timeout");
@@ -43,96 +132,113 @@ export class HaToast extends LitElement {
}
}
public async hide(reason: ToastCloseReason = "programmatic"): Promise<void> {
clearTimeout(this._dismissTimer);
this._closeReason = reason;
await this._popover?.hide();
private _isPopoverOpen(): boolean {
if (!this._toast || !popoverSupported) {
return false;
}
try {
return this._toast.matches(":popover-open");
} catch {
return false;
}
}
public close(reason: ToastCloseReason = "programmatic"): void {
this.hide(reason);
private _showToastPopover(): void {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
return;
}
this._toast.showPopover?.();
}
private _handleAfterHide() {
this.dispatchEvent(
new CustomEvent<ToastClosedEventDetail>("toast-closed", {
detail: { reason: this._closeReason },
bubbles: true,
composed: true,
})
);
this._closeReason = "programmatic";
private _hideToastPopover(): void {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
return;
}
this._toast.hidePopover?.();
}
private async _waitForTransitionEnd(): Promise<void> {
const toastEl = this._toast;
if (!toastEl) {
return;
}
const animations = toastEl.getAnimations({ subtree: true });
if (animations.length === 0) {
return;
}
await Promise.allSettled(animations.map((animation) => animation.finished));
}
protected render() {
const hasAction =
(this._actionElements?.length ?? 0) > 0 ||
(this._dismissElements?.length ?? 0) > 0;
return html`
<div id="toast-anchor" aria-hidden="true"></div>
<wa-popover
for="toast-anchor"
placement="top"
distance="16"
skidding="0"
without-arrow
@wa-after-hide=${this._handleAfterHide}
<div
class=${classMap({
toast: true,
active: this._active,
visible: this._visible,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<div class="toast" role="status" aria-live="polite">
<span class="message">${this.labelText}</span>
<div class="actions">
<slot name="action"></slot>
<slot name="dismiss"></slot>
</div>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
<slot name="action"></slot>
<slot name="dismiss"></slot>
</div>
</wa-popover>
</div>
`;
}
static override styles = css`
#toast-anchor {
position: fixed;
bottom: calc(8px + var(--safe-area-inset-bottom));
inset-inline-start: 50%;
transform: translateX(-50%);
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
wa-popover {
--arrow-size: 0;
--max-width: min(
650px,
calc(
100vw -
16px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
)
);
--show-duration: var(--ha-animation-duration-fast, 150ms);
--hide-duration: var(--ha-animation-duration-fast, 150ms);
}
wa-popover::part(body) {
padding: 0;
border-radius: 4px;
}
.toast {
box-sizing: border-box;
min-width: min(
350px,
calc(
100vw -
16px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
)
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4)
);
max-width: 650px;
inset-inline-start: 50%;
margin: 0;
width: max-content;
height: auto;
border: none;
overflow: hidden;
box-sizing: border-box;
min-width: min(350px, calc(var(--safe-width) - var(--ha-space-4)));
max-width: min(650px, var(--safe-width));
min-height: 48px;
display: flex;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
color: var(--inverse-primary-text-color);
background-color: var(--inverse-surface-color);
padding: var(--ha-space-3) var(--ha-space-4);
color: var(--ha-color-on-neutral-loud);
background-color: var(--ha-color-neutral-10);
border-radius: var(--ha-border-radius-sm);
box-shadow: var(--wa-shadow-l);
opacity: 0;
transform: translate(-50%, var(--ha-space-2));
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease,
transform var(--ha-animation-duration-fast, 150ms) ease;
}
.toast:not(.active) {
display: none;
}
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
}
.message {
@@ -144,23 +250,26 @@ export class HaToast extends LitElement {
display: flex;
align-items: center;
gap: var(--ha-space-2);
color: rgba(255, 255, 255, 0.87);
color: var(--ha-color-on-neutral-loud);
}
.actions:not(.has-action) {
display: none;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.toast {
min-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right)
);
border-radius: 0;
min-width: var(--safe-width);
max-width: var(--safe-width);
border-radius: var(--ha-border-radius-square);
}
}
`;
}
declare global {
interface HTMLElementEventMap {
"toast-closed": CustomEvent<ToastClosedEventDetail>;
interface HASSDomEvents {
"toast-closed": ToastClosedEventDetail;
}
interface HTMLElementTagNameMap {
+21
View File
@@ -11,6 +11,27 @@ import "../ha-svg-icon";
import "./ha-input";
import type { HaInput, InputType } from "./ha-input";
/**
* Home Assistant input with copy button
*
* @element ha-input-copy
* @extends {LitElement}
*
* @summary
* A read-only input component with a copy-to-clipboard button.
* Supports optional value masking with a toggle to reveal the hidden value.
*
* @attr {string} value - The value to display and copy.
* @attr {string} masked-value - An alternative masked display value (for example, "••••••").
* @attr {string} label - Label for the copy button. Defaults to the localized "Copy" text.
* @attr {boolean} readonly - Makes the inner input readonly.
* @attr {boolean} disabled - Disables the inner input.
* @attr {boolean} masked-toggle - Shows a toggle button to reveal/hide the masked value.
* @attr {("text"|"password"|"email"|"number"|"tel"|"url"|"search"|"date"|"datetime-local"|"time"|"color")} type - Sets the input type.
* @attr {string} placeholder - Placeholder text for the input.
* @attr {string} validation-message - Custom validation message.
* @attr {boolean} auto-validate - Validates the input on blur.
*/
@customElement("ha-input-copy")
export class HaInputCopy extends LitElement {
@property({ attribute: "value" }) public value!: string;
+24 -7
View File
@@ -14,6 +14,23 @@ import "../ha-sortable";
import "./ha-input";
import type { HaInput, InputType } from "./ha-input";
/**
* Home Assistant multi-value input component
*
* @element ha-input-multi
* @extends {LitElement}
*
* @summary
* A dynamic list of text inputs that allows adding, removing, and optionally reordering values.
* Useful for managing arrays of strings such as URLs, tags, or other repeated text values.
*
* @attr {boolean} disabled - Disables all inputs and buttons.
* @attr {boolean} sortable - Enables drag-and-drop reordering of items.
* @attr {boolean} item-index - Appends a 1-based index number to each item's label.
* @attr {boolean} update-on-blur - Fires value-changed on blur instead of on input.
*
* @fires value-changed - Fired when the list of values changes. `event.detail.value` contains the new string array.
*/
@customElement("ha-input-multi")
class HaInputMulti extends LitElement {
@property({ attribute: false }) public value?: string[];
@@ -22,19 +39,19 @@ class HaInputMulti extends LitElement {
@property() public label?: string;
@property({ attribute: false }) public helper?: string;
@property() public helper?: string;
@property({ attribute: false }) public inputType?: InputType;
@property({ attribute: "input-type" }) public inputType?: InputType;
@property({ attribute: false }) public inputSuffix?: string;
@property({ attribute: "input-suffix" }) public inputSuffix?: string;
@property({ attribute: false }) public inputPrefix?: string;
@property({ attribute: "input-prefix" }) public inputPrefix?: string;
@property({ attribute: false }) public autocomplete?: string;
@property() public autocomplete?: string;
@property({ attribute: false }) public addLabel?: string;
@property({ attribute: "add-label" }) public addLabel?: string;
@property({ attribute: false }) public removeLabel?: string;
@property({ attribute: "remove-label" }) public removeLabel?: string;
@property({ attribute: "item-index", type: Boolean })
public itemIndex = false;
+51
View File
@@ -0,0 +1,51 @@
import { consume, type ContextType } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import { html, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import { localizeContext } from "../../data/context";
import { HaInput } from "./ha-input";
/**
* Home Assistant search input component
*
* @element ha-input-search
* @extends {HaInput}
*
* @summary
* A pre-configured search input that extends `ha-input` with a magnify icon, clear button,
* and a localized "Search" placeholder. Autocomplete is disabled by default.
*/
@customElement("ha-input-search")
export class HaInputSearch extends HaInput {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
constructor() {
super();
this.withClear = true;
this.autocomplete = this.autocomplete || "off";
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (
!this.label &&
!this.placeholder &&
(!this.hasUpdated || changedProps.has("localize"))
) {
this.placeholder = this.localize("ui.common.search");
}
}
protected renderStartDefault() {
return html`<ha-svg-icon slot="start" .path=${mdiMagnify}></ha-svg-icon>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-input-search": HaInputSearch;
}
}
+107 -17
View File
@@ -3,13 +3,21 @@ import "@home-assistant/webawesome/dist/components/input/input";
import type WaInput from "@home-assistant/webawesome/dist/components/input/input";
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import { mdiClose, mdiEye, mdiEyeOff } from "@mdi/js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import {
LitElement,
type PropertyValues,
type TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { stopPropagation } from "../../common/dom/stop_propagation";
import "../ha-icon-button";
import "../ha-svg-icon";
import "../ha-tooltip";
export type InputType =
@@ -25,18 +33,59 @@ export type InputType =
| "color"
| "url";
/**
* Home Assistant input component
*
* @element ha-input
* @extends {LitElement}
*
* @summary
* A text input component supporting Home Assistant theming and validation, based on webawesome input.
* Supports multiple input types including text, number, password, email, search, and more.
*
* @slot start - Content placed before the input (usually for icons or prefixes).
* @slot end - Content placed after the input (usually for icons or suffixes).
* @slot label - Custom label content. Overrides the `label` property.
* @slot hint - Custom hint content. Overrides the `hint` property.
* @slot clear-icon - Custom clear icon. Defaults to a close icon button.
* @slot show-password-icon - Custom show password icon. Defaults to an eye icon button.
* @slot hide-password-icon - Custom hide password icon. Defaults to an eye-off icon button.
*
* @csspart wa-base - The underlying wa-input base wrapper.
* @csspart wa-hint - The underlying wa-input hint container.
* @csspart wa-input - The underlying wa-input input element.
*
* @cssprop --ha-input-padding-top - Padding above the input.
* @cssprop --ha-input-padding-bottom - Padding below the input. Defaults to `var(--ha-space-2)`.
* @cssprop --ha-input-text-align - Text alignment of the input. Defaults to `start`.
* @cssprop --ha-input-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
*
* @attr {("material"|"outlined")} appearance - Sets the input appearance style. "material" is the default filled style, "outlined" uses a bordered style.
* @attr {("date"|"datetime-local"|"email"|"number"|"password"|"search"|"tel"|"text"|"time"|"color"|"url")} type - Sets the input type.
* @attr {string} label - The input's label text.
* @attr {string} hint - The input's hint/helper text.
* @attr {string} placeholder - Placeholder text shown when the input is empty.
* @attr {boolean} with-clear - Adds a clear button when the input is not empty.
* @attr {boolean} readonly - Makes the input readonly.
* @attr {boolean} disabled - Disables the input and prevents user interaction.
* @attr {boolean} required - Makes the input a required field.
* @attr {boolean} password-toggle - Adds a button to toggle the password visibility.
* @attr {boolean} without-spin-buttons - Hides the browser's built-in spin buttons for number inputs.
* @attr {boolean} auto-validate - Validates the input on blur instead of on form submit.
* @attr {boolean} invalid - Marks the input as invalid.
* @attr {boolean} inset-label - Uses an inset label style where the label stays inside the input.
* @attr {string} validation-message - Custom validation message shown when the input is invalid.
*/
@customElement("ha-input")
export class HaInput extends LitElement {
@property({ reflect: true }) appearance: "material" | "outlined" = "material";
@property({ reflect: true })
public type: InputType = "text";
@property()
public value?: string;
/** Draws a pill-style input with rounded edges. */
@property({ type: Boolean })
public pill = false;
/** The input's label. */
@property()
public label = "";
@@ -319,12 +368,10 @@ export class HaInput extends LitElement {
>${this._renderLabel(this.label, this.required)}</slot
>`
: nothing}
<slot
name="start"
slot="start"
@slotchange=${this._syncStartSlotWidth}
></slot>
<slot name="end" slot="end"></slot>
<slot name="start" slot="start" @slotchange=${this._syncStartSlotWidth}>
${this.renderStartDefault()}
</slot>
<slot name="end" slot="end"> ${this.renderEndDefault()} </slot>
<slot name="clear-icon" slot="clear-icon">
<ha-icon-button .path=${mdiClose}></ha-icon-button>
</slot>
@@ -357,6 +404,14 @@ export class HaInput extends LitElement {
`;
}
protected renderStartDefault(): TemplateResult | typeof nothing {
return nothing;
}
protected renderEndDefault(): TemplateResult | typeof nothing {
return nothing;
}
private _handleInput() {
this.value = this._input?.value ?? undefined;
if (this._invalid && this._input?.checkValidity()) {
@@ -430,6 +485,9 @@ export class HaInput extends LitElement {
padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2));
text-align: var(--ha-input-text-align, start);
}
:host([appearance="outlined"]) {
padding-bottom: var(--ha-input-padding-bottom);
}
wa-input {
flex: 1;
min-width: 0;
@@ -455,7 +513,7 @@ export class HaInput extends LitElement {
font-size: var(--ha-font-size-m);
}
:host(:focus-within) wa-input::part(label) {
:host([appearance="material"]:focus-within) wa-input::part(label) {
color: var(--primary-color);
}
@@ -465,7 +523,8 @@ export class HaInput extends LitElement {
}
wa-input.label-raised::part(label),
:host(:focus-within) wa-input::part(label) {
:host(:focus-within) wa-input::part(label),
:host([type="date"]) wa-input::part(label) {
padding-top: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
}
@@ -483,7 +542,19 @@ export class HaInput extends LitElement {
transition: background-color var(--wa-transition-normal) ease-in-out;
}
wa-input::part(base)::after {
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: 32px;
padding: 0 var(--ha-space-2);
}
:host([appearance="outlined"]) wa-input::part(base) {
border: 1px solid var(--ha-color-border-neutral-quiet);
background-color: var(--card-background-color);
border-radius: var(--ha-border-radius-md);
transition: border-color var(--wa-transition-normal) ease-in-out;
}
:host([appearance="material"]) ::part(base)::after {
content: "";
position: absolute;
bottom: 0;
@@ -496,13 +567,15 @@ export class HaInput extends LitElement {
background-color var(--wa-transition-normal) ease-in-out;
}
:host(:focus-within) wa-input::part(base)::after {
:host([appearance="material"]:focus-within) wa-input::part(base)::after {
height: 2px;
background-color: var(--primary-color);
}
:host(:focus-within) wa-input.invalid::part(base)::after,
wa-input.invalid:not([disabled])::part(base)::after {
:host([appearance="material"]:focus-within)
wa-input.invalid::part(base)::after,
:host([appearance="material"])
wa-input.invalid:not([disabled])::part(base)::after {
background-color: var(--ha-color-border-danger-normal);
}
@@ -510,6 +583,7 @@ export class HaInput extends LitElement {
padding-top: var(--ha-space-3);
padding-inline-start: var(--input-padding-inline-start, 0);
}
wa-input.no-label::part(input) {
padding-top: 0;
}
@@ -535,10 +609,21 @@ export class HaInput extends LitElement {
background-color: var(--ha-color-form-background-hover);
}
:host([appearance="outlined"]) wa-input::part(base):hover {
border-color: var(--ha-color-border-neutral-normal);
}
:host([appearance="outlined"]:focus-within) wa-input::part(base) {
border-color: var(--primary-color);
}
wa-input:disabled::part(base) {
background-color: var(--ha-color-form-background-disabled);
}
wa-input:disabled::part(label) {
opacity: 0.5;
}
wa-input::part(hint) {
height: var(--ha-space-5);
margin-block-start: 0;
@@ -560,6 +645,11 @@ export class HaInput extends LitElement {
wa-input::part(end) {
color: var(--ha-color-text-secondary);
}
:host([appearance="outlined"]) wa-input.no-label {
--ha-icon-button-size: 24px;
--mdc-icon-size: 18px;
}
`;
}
-111
View File
@@ -1,111 +0,0 @@
import { mdiClose, mdiMagnify } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-icon-button";
import "./ha-outlined-text-field";
import type { HaOutlinedTextField } from "./ha-outlined-text-field";
import "./ha-svg-icon";
@customElement("search-input-outlined")
class SearchInputOutlined extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public filter?: string;
@property({ type: Boolean })
public suffix = false;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: String })
public label?: string;
@property({ type: String })
public placeholder?: string;
public focus() {
this._input?.focus();
}
@query("ha-outlined-text-field", true) private _input!: HaOutlinedTextField;
protected render(): TemplateResult {
const placeholder =
this.placeholder || this.hass.localize("ui.common.search");
return html`
<ha-outlined-text-field
.autofocus=${this.autofocus}
.aria-label=${this.label || this.hass.localize("ui.common.search")}
.placeholder=${placeholder}
.value=${this.filter || ""}
icon
.iconTrailing=${this.filter || this.suffix}
@input=${this._filterInputChanged}
dense
>
<slot name="prefix" slot="leading-icon">
<ha-svg-icon
tabindex="-1"
class="prefix"
.path=${mdiMagnify}
></ha-svg-icon>
</slot>
${this.filter
? html`<ha-icon-button
aria-label="Clear input"
slot="trailing-icon"
@click=${this._clearSearch}
.path=${mdiClose}
>
</ha-icon-button>`
: nothing}
</ha-outlined-text-field>
`;
}
private async _filterChanged(value: string) {
fireEvent(this, "value-changed", { value: String(value) });
}
private async _filterInputChanged(e) {
this._filterChanged(e.target.value);
}
private async _clearSearch() {
this._filterChanged("");
}
static styles = css`
:host {
display: inline-flex;
/* For iOS */
z-index: 0;
}
ha-outlined-text-field {
display: block;
width: 100%;
--ha-outlined-field-container-color: var(--card-background-color);
}
ha-svg-icon,
ha-icon-button {
--ha-icon-button-size: 24px;
height: var(--ha-icon-button-size);
display: flex;
color: var(--primary-text-color);
}
ha-svg-icon {
outline: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"search-input-outlined": SearchInputOutlined;
}
}
-107
View File
@@ -1,107 +0,0 @@
import { mdiClose, mdiMagnify } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
@customElement("search-input")
class SearchInput extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public filter?: string;
@property({ type: Boolean })
public suffix = false;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: String })
public label?: string;
public focus() {
this._input?.focus();
}
@query("ha-textfield", true) private _input!: HaTextField;
protected render(): TemplateResult {
return html`
<ha-textfield
.autofocus=${this.autofocus}
autocomplete="off"
.label=${this.label || this.hass.localize("ui.common.search")}
.value=${this.filter || ""}
icon
.iconTrailing=${this.filter || this.suffix}
@input=${this._filterInputChanged}
>
<slot name="prefix" slot="leadingIcon">
<ha-svg-icon
tabindex="-1"
class="prefix"
.path=${mdiMagnify}
></ha-svg-icon>
</slot>
<div class="trailing" slot="trailingIcon">
${this.filter &&
html`
<ha-icon-button
@click=${this._clearSearch}
.label=${this.hass.localize("ui.common.clear")}
.path=${mdiClose}
class="clear-button"
></ha-icon-button>
`}
<slot name="suffix"></slot>
</div>
</ha-textfield>
`;
}
private async _filterChanged(value: string) {
fireEvent(this, "value-changed", { value: String(value) });
}
private async _filterInputChanged(e) {
this._filterChanged(e.target.value);
}
private async _clearSearch() {
this._filterChanged("");
}
static styles = css`
:host {
display: inline-flex;
}
ha-svg-icon,
ha-icon-button {
color: var(--primary-text-color);
}
ha-svg-icon {
outline: none;
}
.clear-button {
--mdc-icon-size: 20px;
}
ha-textfield {
display: inherit;
}
.trailing {
display: flex;
align-items: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"search-input": SearchInput;
}
}
@@ -535,7 +535,7 @@ export class HaTargetPickerItemRow extends LitElement {
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities)
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device } = stateObject
? getEntityContext(
+1 -1
View File
@@ -15,7 +15,7 @@ interface HassioHardwareAudioList {
};
}
interface HardwareDevice {
export interface HardwareDevice {
attributes: Record<string, string>;
by_id: null | string;
dev_path: string;
+12 -7
View File
@@ -464,6 +464,15 @@ export const convertStatisticsToHistory = (
return statisticsHistory;
};
export const limitedHistoryFromStateObj = (
state: HassEntity
): EntityHistoryState[] => [
{
s: state.state,
a: state.attributes,
lu: new Date(state.last_updated).getTime() / 1000,
},
];
export const computeHistory = (
hass: HomeAssistant,
stateHistory: HistoryStates,
@@ -484,13 +493,9 @@ export const computeHistory = (
if (entity in stateHistory) {
localStateHistory[entity] = stateHistory[entity];
} else if (hass.states[entity]) {
localStateHistory[entity] = [
{
s: hass.states[entity].state,
a: hass.states[entity].attributes,
lu: new Date(hass.states[entity].last_updated).getTime() / 1000,
},
];
localStateHistory[entity] = limitedHistoryFromStateObj(
hass.states[entity]
);
}
});
+2
View File
@@ -26,6 +26,7 @@ import {
mdiHomeAutomation,
mdiImage,
mdiImageFilterFrames,
mdiLedOn,
mdiLightbulb,
mdiMapMarkerRadius,
mdiMicrophoneMessage,
@@ -89,6 +90,7 @@ export const FALLBACK_DOMAIN_ICONS = {
homekit: mdiHomeAutomation,
image_processing: mdiImageFilterFrames,
image: mdiImage,
infrared: mdiLedOn,
input_boolean: mdiToggleSwitch,
input_button: mdiButtonPointer,
input_datetime: mdiCalendarClock,
+1
View File
@@ -12,6 +12,7 @@ export const SCENE_IGNORED_DOMAINS = [
"device_tracker",
"event",
"image_processing",
"infrared",
"input_button",
"persistent_notification",
"person",
+3
View File
@@ -22,6 +22,8 @@ import type {
} from "./entity/entity_registry";
import type { EntitySources } from "./entity/entity_sources";
export type ThresholdMode = "crossed" | "changed" | "is";
export type Selector =
| ActionSelector
| AddonSelector
@@ -366,6 +368,7 @@ export interface NumberSelector {
export interface NumericThresholdSelector {
numeric_threshold: {
mode?: ThresholdMode;
unit_of_measurement?: readonly string[];
number?: NumberSelector["number"];
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
+1 -7
View File
@@ -1,13 +1,7 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
export interface ThemeVars {
// Incomplete
"primary-color": string;
"text-primary-color": string;
"accent-color": string;
[key: string]: string;
}
export type ThemeVars = Record<string, string>;
export type Theme = ThemeVars & {
modes?: {
@@ -16,6 +16,7 @@ import "../../../components/ha-humidifier-state";
import "../../../components/ha-select";
import "../../../components/ha-slider";
import "../../../components/ha-time-input";
import "../../../components/input/ha-input";
import { isTiltOnly } from "../../../data/cover";
import { isUnavailableState } from "../../../data/entity/entity";
import type { ImageEntity } from "../../../data/image";
@@ -72,7 +73,7 @@ class EntityPreviewRow extends LitElement {
min-width: 45px;
text-align: end;
}
ha-textfield {
ha-input {
text-align: end;
direction: ltr !important;
}
@@ -273,18 +274,23 @@ class EntityPreviewRow extends LitElement {
</span>
</div>
`
: html` <div class="numberflex numberstate">
<ha-textfield
autoValidate
: html`<div class="numberflex numberstate">
<ha-input
auto-validate
.disabled=${isUnavailableState(stateObj.state)}
pattern="[0-9]+([\\.][0-9]+)?"
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
.max=${Number(stateObj.attributes.max)}
.value=${stateObj.state}
.suffix=${stateObj.attributes.unit_of_measurement}
type="number"
></ha-textfield>
>
${stateObj.attributes.unit_of_measurement
? html`<span slot="end"
>${stateObj.attributes.unit_of_measurement}</span
>`
: nothing}
</ha-input>
</div>`}
`;
}
@@ -323,7 +329,7 @@ class EntityPreviewRow extends LitElement {
if (domain === "text") {
return html`
<ha-textfield
<ha-input
.label=${computeStateName(stateObj)}
.disabled=${isUnavailableState(stateObj.state)}
.value=${stateObj.state}
@@ -333,7 +339,7 @@ class EntityPreviewRow extends LitElement {
.pattern=${stateObj.attributes.pattern}
.type=${stateObj.attributes.mode}
placeholder=${this.hass!.localize("ui.card.text.emtpy_value")}
></ha-textfield>
></ha-input>
`;
}
@@ -11,6 +11,8 @@ import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import "../../components/ha-area-picker";
import "../../components/ha-button";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
import { getConfigEntries } from "../../data/config_entries";
import type { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow";
@@ -22,7 +24,7 @@ import {
type EntityRegistryDisplayEntry,
} from "../../data/entity/entity_registry";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { showAlertDialog } from "../generic/show-dialog-box";
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
@@ -162,7 +164,7 @@ class StepFlowCreateEntry extends LitElement {
: nothing}
</div>
</div>
<ha-textfield
<ha-input
.label=${localize(
"ui.panel.config.integrations.config_flow.device_name"
)}
@@ -174,7 +176,7 @@ class StepFlowCreateEntry extends LitElement {
computeDeviceName(device)}
@change=${this._deviceNameChanged}
.device=${device.id}
></ha-textfield>
></ha-input>
<ha-area-picker
.hass=${this.hass}
.device=${device.id}
@@ -278,7 +280,7 @@ class StepFlowCreateEntry extends LitElement {
}
}
private async _areaPicked(ev: CustomEvent) {
private async _areaPicked(ev: ValueChangedEvent<string>) {
const picker = ev.currentTarget as any;
const device = picker.device;
const area = ev.detail.value;
@@ -290,9 +292,9 @@ class StepFlowCreateEntry extends LitElement {
this.requestUpdate("_deviceUpdate");
}
private _deviceNameChanged(ev): void {
const picker = ev.currentTarget as any;
const device = picker.device;
private _deviceNameChanged(ev: InputEvent): void {
const picker = ev.currentTarget as HaInput;
const device = (picker as any).device;
const name = picker.value;
if (!(device in this._deviceUpdate)) {
@@ -343,12 +345,11 @@ class StepFlowCreateEntry extends LitElement {
.secondary {
color: var(--secondary-text-color);
}
ha-textfield,
ha-area-picker {
display: block;
}
ha-textfield {
margin: 8px 0;
ha-input {
margin: var(--ha-space-2) 0;
}
.buttons > *:last-child {
margin-left: auto;
+11 -10
View File
@@ -5,10 +5,10 @@ import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-control-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-textfield";
import "../../components/ha-dialog";
import type { HaTextField } from "../../components/ha-textfield";
import "../../components/ha-dialog-footer";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
import type { EnterCodeDialogParams } from "./show-enter-code-dialog";
@@ -39,7 +39,7 @@ export class DialogEnterCode
@state() private _open = false;
@query("#code") private _input?: HaTextField;
@query("#code") private _input?: HaInput;
@state() private _showClearButton = false;
@@ -97,7 +97,7 @@ export class DialogEnterCode
}
private _inputValueChange(e) {
const field = e.currentTarget as HaTextField;
const field = e.currentTarget as HaInput;
const val = field.value;
this._showClearButton = !!val;
}
@@ -119,7 +119,7 @@ export class DialogEnterCode
width="small"
@closed=${this._dialogClosed}
>
<ha-textfield
<ha-input
class="input"
?autofocus=${!this._narrow}
id="code"
@@ -129,7 +129,7 @@ export class DialogEnterCode
validateOnInitialRender
pattern=${ifDefined(this._dialogParams.codePattern)}
inputmode="text"
></ha-textfield>
></ha-input>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -157,14 +157,15 @@ export class DialogEnterCode
@closed=${this._dialogClosed}
>
<div class="container">
<ha-textfield
<ha-input
@input=${this._inputValueChange}
id="code"
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password"
inputmode="numeric"
?autofocus=${!this._narrow}
></ha-textfield>
password-toggle
></ha-input>
<div class="keypad">
${BUTTONS.map((value) =>
value === ""
@@ -212,7 +213,7 @@ export class DialogEnterCode
/* Place above other dialogs */
--dialog-z-index: 104;
}
ha-textfield {
ha-input {
width: 100%;
margin: auto;
}
+15 -10
View File
@@ -5,12 +5,12 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog-header";
import "../../components/ha-svg-icon";
import "../../components/ha-textfield";
import type { HaTextField } from "../../components/ha-textfield";
import "../../components/ha-dialog";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import type { HomeAssistant } from "../../types";
import type { DialogBoxParams } from "./show-dialog-box";
@@ -28,7 +28,7 @@ class DialogBox extends LitElement {
@state() private _validInput = true;
@query("ha-textfield") private _textField?: HaTextField;
@query("ha-input") private _textField?: HaInput;
private _closePromise?: Promise<void>;
@@ -73,7 +73,6 @@ class DialogBox extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
type=${confirmPrompt ? "alert" : "standard"}
?prevent-scrim-close=${confirmPrompt}
@@ -104,19 +103,21 @@ class DialogBox extends LitElement {
: nothing}
${dialogTitle}
</span>
${this._params.subtitle
? html`<span slot="subtitle">${this._params.subtitle}</span>`
: nothing}
</ha-dialog-header>
<div id="dialog-box-description">
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}
${this._params.prompt
? html`
<ha-textfield
<ha-input
autofocus
value=${ifDefined(this._params.defaultValue)}
.placeholder=${this._params.placeholder}
.label=${this._params.inputLabel
? this._params.inputLabel
: ""}
.suffix=${this._params.inputSuffix}
.type=${this._params.inputType
? this._params.inputType
: "text"}
@@ -124,9 +125,13 @@ class DialogBox extends LitElement {
.max=${this._params.inputMax}
.disabled=${this._loading}
@input=${this._validateInput}
></ha-textfield>
>
${this._params.inputSuffix
? html`<span slot="end">${this._params.inputSuffix}</span>`
: nothing}
</ha-input>
`
: ""}
: nothing}
</div>
<ha-dialog-footer slot="footer">
${confirmPrompt
@@ -240,7 +245,7 @@ class DialogBox extends LitElement {
.secondary {
color: var(--secondary-text-color);
}
ha-textfield {
ha-input {
width: 100%;
}
.title.alert {
+1
View File
@@ -5,6 +5,7 @@ interface BaseDialogBoxParams {
confirmText?: string;
text?: string | TemplateResult;
title?: string;
subtitle?: string;
warning?: boolean;
}
@@ -7,13 +7,13 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import "../../../../components/ha-button";
import "../../../../components/ha-control-button";
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import "../../../../components/ha-select";
import "../../../../components/ha-textfield";
import "../../../../components/ha-dialog";
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
import "../../../../components/input/ha-input";
import { SirenEntityFeature } from "../../../../data/siren";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
@@ -95,27 +95,31 @@ class MoreInfoSirenAdvancedControls extends LitElement {
: nothing}
${supportsVolume
? html`
<ha-textfield
<ha-input
type="number"
.label=${this.hass.localize("ui.components.siren.volume")}
.suffix=${"%"}
.value=${this._volume ? this._volume * 100 : undefined}
.value=${this._volume ? `${this._volume * 100}` : undefined}
@change=${this._handleVolumeChange}
.min=${0}
.max=${100}
.step=${1}
></ha-textfield>
>
<span slot="end">%</span>
</ha-input>
`
: nothing}
${supportsDuration
? html`
<ha-textfield
<ha-input
type="number"
.label=${this.hass.localize("ui.components.siren.duration")}
.value=${this._duration}
suffix="s"
.value=${this._duration !== undefined
? this._duration.toString()
: undefined}
@change=${this._handleDurationChange}
></ha-textfield>
>
<span slot="end">s</span>
</ha-input>
`
: nothing}
</div>
@@ -4,7 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import "../../../components/ha-textfield";
import "../../../components/input/ha-input";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-configurator")
@@ -33,15 +33,15 @@ export class MoreInfoConfigurator extends LitElement {
? html`<ha-alert alert-type="error">
${this.stateObj.attributes.errors}
</ha-alert>`
: ""}
: nothing}
${this.stateObj.attributes.fields.map(
(field) =>
html`<ha-textfield
html`<ha-input
.label=${field.name}
.name=${field.id}
.type=${field.type}
@change=${this._fieldChanged}
></ha-textfield>`
></ha-input>`
)}
${this.stateObj.attributes.submit_caption
? html`<p class="submit">
@@ -53,7 +53,7 @@ export class MoreInfoConfigurator extends LitElement {
${this.stateObj.attributes.submit_caption}
</ha-button>
</p>`
: ""}
: nothing}
</div>
`;
}
@@ -82,6 +82,7 @@ class MoreInfoInputDatetime extends LitElement {
display: flex;
align-items: center;
justify-content: flex-end;
--ha-input-padding-bottom: 0;
}
ha-date-input + ha-time-input {
margin-left: var(--ha-space-1);
+2 -2
View File
@@ -536,9 +536,9 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities)
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry)
? computeEntityEntryName(this._entry, this.hass.devices)
: entityId;
const deviceName = context?.device
+5 -1
View File
@@ -107,7 +107,11 @@ class MoreInfoContent extends LitElement {
if (!stateObj) {
return null;
}
const entityName = computeEntityName(stateObj, hass.entities);
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area } = getEntityContext(
stateObj,
hass.entities,
@@ -4,13 +4,14 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import type { HaSelectSelectEvent } from "../../components/ha-select";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import "../../components/ha-select";
import type { HaSelectSelectEvent } from "../../components/ha-select";
import "../../components/ha-tts-voice-picker";
import "../../components/input/ha-input";
import type { AssistPipeline } from "../../data/assist_pipeline";
import {
listAssistPipelines,
@@ -99,14 +100,14 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
: nothing}
<div class="rows">
<div class="row">
<ha-textfield
<ha-input
.label=${this.hass.localize(
"ui.panel.config.integrations.config_flow.device_name"
)}
.placeholder=${computeDeviceNameDisplay(device, this.hass)}
.value=${this._deviceName ?? computeDeviceName(device)}
@change=${this._deviceNameChanged}
></ha-textfield>
></ha-input>
</div>
${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1
+17 -16
View File
@@ -12,7 +12,7 @@ import {
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
} from "@mdi/js";
import type { TemplateResult, PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -29,14 +29,15 @@ import type {
} from "../components/data-table/ha-data-table";
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
import "../components/ha-button";
import "../components/ha-dialog-footer";
import "../components/ha-dialog";
import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import "../components/ha-icon-button";
import "../components/ha-svg-icon";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
import "../components/ha-dropdown-item";
import "../components/search-input-outlined";
import "../components/ha-icon-button";
import "../components/ha-svg-icon";
import "../components/input/ha-input-search";
import type { HaInputSearch } from "../components/input/ha-input-search";
import { KeyboardShortcutMixin } from "../mixins/keyboard-shortcut-mixin";
import type { HomeAssistant, Route } from "../types";
import "./hass-tabs-subpage";
@@ -193,7 +194,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@query("ha-data-table", true) private _dataTable!: HaDataTable;
@query("search-input-outlined") private _searchInput!: HTMLElement;
@query("ha-input-search") private _searchInput!: HaInputSearch;
protected supportedShortcuts(): SupportedShortcuts {
return {
@@ -266,14 +267,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
</ha-assist-chip>`
: nothing;
const searchBar = html`<search-input-outlined
.hass=${this.hass}
.filter=${this.filter}
@value-changed=${this._handleSearchChange}
.label=${this.searchLabel}
const searchBar = html`<ha-input-search
appearance="outlined"
.value=${this.filter}
@input=${this._handleSearchChange}
.placeholder=${this.searchLabel}
>
</search-input-outlined>`;
</ha-input-search>`;
const sortByMenu = Object.values(this.columns).find((col) => col.sortable)
? html`
@@ -715,11 +715,12 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
this._dataTable.clearSelection();
};
private _handleSearchChange(ev: CustomEvent) {
if (this.filter === ev.detail.value) {
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
if (this.filter === target.value) {
return;
}
this.filter = ev.detail.value;
this.filter = target.value ?? "";
fireEvent(this, "search-changed", { value: this.filter });
}
@@ -795,7 +796,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
search-input-outlined {
ha-input-search {
flex: 1;
}
.search-toolbar {
+2
View File
@@ -52,6 +52,7 @@ export class HomeAssistantMain extends LitElement {
return html`
<ha-snowflakes .hass=${this.hass} .narrow=${this.narrow}></ha-snowflakes>
<ha-retro .hass=${this.hass} .narrow=${this.narrow}></ha-retro>
<ha-drawer
.type=${sidebarNarrow ? "modal" : ""}
.open=${sidebarNarrow ? this._drawerOpen : false}
@@ -79,6 +80,7 @@ export class HomeAssistantMain extends LitElement {
protected firstUpdated() {
import(/* webpackPreload: true */ "../components/ha-sidebar");
import("../components/ha-snowflakes");
import("../components/ha-retro");
if (this.hass.auth.external) {
this._externalSidebar =
+4 -2
View File
@@ -1,10 +1,12 @@
import { mdiClose } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
import "../components/ha-toast";
import type { ToastClosedEventDetail } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
@@ -39,7 +41,7 @@ class NotificationManager extends LitElement {
await this._toast?.hide();
}
if (!parameters || parameters.duration === 0) {
if (parameters.duration === 0) {
this._parameters = undefined;
return;
}
@@ -57,7 +59,7 @@ class NotificationManager extends LitElement {
this._toast?.show();
}
private _toastClosed(_ev: HTMLElementEventMap["toast-closed"]) {
private _toastClosed(_ev: HASSDomEvent<ToastClosedEventDetail>) {
this._parameters = undefined;
}
+11 -38
View File
@@ -16,8 +16,8 @@ import "../components/ha-list";
import "../components/ha-list-item";
import "../components/ha-radio";
import "../components/ha-spinner";
import "../components/ha-textfield";
import type { HaTextField } from "../components/ha-textfield";
import "../components/input/ha-input";
import type { HaInput } from "../components/input/ha-input";
import "../components/map/ha-locations-editor";
import type {
HaLocationsEditor,
@@ -103,22 +103,20 @@ class OnboardingLocation extends LitElement {
</p>
<div class="location-search">
<ha-textfield
<ha-input
label=${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.address_label"
)}
.disabled=${this._working}
icon
iconTrailing
@keyup=${this._addressSearch}
>
<ha-svg-icon slot="leadingIcon" .path=${mdiMagnify}></ha-svg-icon>
<ha-svg-icon slot="start" .path=${mdiMagnify}></ha-svg-icon>
${this._working
? html` <ha-spinner slot="trailingIcon" size="small"></ha-spinner> `
? html`<ha-spinner slot="end" size="small"></ha-spinner>`
: html`
<ha-icon-button
@click=${this._handleButtonClick}
slot="trailingIcon"
slot="end"
.disabled=${this._working}
.label=${this.onboardingLocalize(
this._search
@@ -128,7 +126,7 @@ class OnboardingLocation extends LitElement {
.path=${this._search ? mdiMapSearchOutline : mdiCrosshairsGps}
></ha-icon-button>
`}
</ha-textfield>
</ha-input>
${this._places !== undefined
? html`
<ha-list activatable>
@@ -204,10 +202,7 @@ class OnboardingLocation extends LitElement {
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
setTimeout(
() => this.renderRoot.querySelector("ha-textfield")!.focus(),
100
);
setTimeout(() => this.renderRoot.querySelector("ha-input")!.focus(), 100);
this.addEventListener("keyup", (ev) => {
if (ev.key === "Enter") {
this._save(ev);
@@ -299,11 +294,11 @@ class OnboardingLocation extends LitElement {
private async _addressSearch(ev: KeyboardEvent) {
ev.stopPropagation();
this._search = (ev.currentTarget as HaTextField).value.length > 0;
this._search = ((ev.currentTarget as HaInput).value ?? "").length > 0;
if (ev.key !== "Enter") {
return;
}
this._searchAddress((ev.currentTarget as HaTextField).value);
this._searchAddress((ev.currentTarget as HaInput).value ?? "");
}
private async _searchAddress(address: string) {
@@ -477,28 +472,6 @@ class OnboardingLocation extends LitElement {
margin-top: 32px;
margin-bottom: 32px;
}
ha-textfield {
display: block;
}
ha-textfield > ha-icon-button {
position: absolute;
top: 10px;
right: 10px;
inset-inline-end: 10px;
inset-inline-start: initial;
--ha-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
inset-inline-start: initial;
inset-inline-end: 10px;
direction: var(--direction);
}
ha-textfield > ha-spinner {
position: relative;
left: 12px;
inset-inline-start: 12px;
inset-inline-end: initial;
}
ha-locations-editor {
display: block;
height: 300px;
@@ -519,7 +492,7 @@ class OnboardingLocation extends LitElement {
height: 72px;
}
.attribution {
/* textfield helper style */
/* input helper style */
margin: 0;
padding: 4px 16px 12px 16px;
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
@@ -271,6 +271,7 @@ class DialogCalendarEventDetail extends LitElement {
color: var(--secondary-text-color);
max-width: 300px;
overflow-wrap: break-word;
white-space: pre-line;
}
`,
];
@@ -10,6 +10,11 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
formatDate,
formatTime,
parseDate,
} from "../../common/datetime/calc_date";
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
import { fireEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
@@ -19,13 +24,13 @@ import "../../components/entity/ha-entity-picker";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-date-input";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-formfield";
import "../../components/ha-switch";
import "../../components/ha-textarea";
import "../../components/ha-textfield";
import "../../components/ha-time-input";
import "../../components/ha-dialog";
import "../../components/input/ha-input";
import type { CalendarEventMutableParams } from "../../data/calendar";
import {
CalendarEntityFeature,
@@ -40,11 +45,6 @@ import "../lovelace/components/hui-generic-entity-row";
import "./ha-recurrence-rule-editor";
import { showConfirmEventDialog } from "./show-confirm-event-dialog-box";
import type { CalendarEventEditDialogParams } from "./show-dialog-calendar-event-editor";
import {
formatDate,
formatTime,
parseDate,
} from "../../common/datetime/calc_date";
const CALENDAR_DOMAINS = ["calendar"];
@@ -170,7 +170,7 @@ class DialogCalendarEventEditor extends LitElement {
>`
: ""}
<ha-textfield
<ha-input
class="summary"
name="summary"
.label=${this.hass.localize("ui.components.calendar.event.summary")}
@@ -179,8 +179,8 @@ class DialogCalendarEventEditor extends LitElement {
@input=${this._handleSummaryChanged}
.validationMessage=${this.hass.localize("ui.common.error_required")}
autofocus
></ha-textfield>
<ha-textfield
></ha-input>
<ha-input
class="location"
name="location"
.label=${this.hass.localize(
@@ -188,7 +188,7 @@ class DialogCalendarEventEditor extends LitElement {
)}
.value=${this._location}
@change=${this._handleLocationChanged}
></ha-textfield>
></ha-input>
<ha-textarea
class="description"
name="description"
@@ -624,7 +624,6 @@ class DialogCalendarEventEditor extends LitElement {
display: block;
margin-bottom: 16px;
}
ha-textfield,
ha-textarea {
display: block;
}
@@ -12,7 +12,7 @@ import "../../components/chips/ha-filter-chip";
import "../../components/ha-date-input";
import "../../components/ha-select";
import type { HaSelectSelectEvent } from "../../components/ha-select";
import "../../components/ha-textfield";
import "../../components/input/ha-input";
import type { HomeAssistant } from "../../types";
import type {
MonthlyRepeatItem,
@@ -231,7 +231,7 @@ export class RecurrenceRuleEditor extends LitElement {
renderInterval() {
return html`
<ha-textfield
<ha-input
id="interval"
label=${this.hass.localize(
"ui.components.calendar.event.repeat.interval.label"
@@ -239,12 +239,15 @@ export class RecurrenceRuleEditor extends LitElement {
type="number"
min="1"
.value=${this._interval}
.suffix=${this.hass.localize(
`ui.components.calendar.event.repeat.interval.${this
._freq!}` as LocalizeKeys
)}
@change=${this._onIntervalChange}
></ha-textfield>
>
<span slot="end">
${this.hass.localize(
`ui.components.calendar.event.repeat.interval.${this
._freq!}` as LocalizeKeys
)}
</span>
</ha-input>
`;
}
@@ -267,7 +270,7 @@ export class RecurrenceRuleEditor extends LitElement {
</ha-select>
${this._end === "after"
? html`
<ha-textfield
<ha-input
id="after"
label=${this.hass.localize(
"ui.components.calendar.event.repeat.end_after.label"
@@ -275,11 +278,14 @@ export class RecurrenceRuleEditor extends LitElement {
type="number"
min="1"
.value=${this._count!}
suffix=${this.hass.localize(
"ui.components.calendar.event.repeat.end_after.ocurrences"
)}
@change=${this._onCountChange}
></ha-textfield>
>
<span slot="end">
${this.hass.localize(
"ui.components.calendar.event.repeat.end_after.ocurrences"
)}
</span>
</ha-input>
`
: nothing}
${this._end === "on"
@@ -452,15 +458,16 @@ export class RecurrenceRuleEditor extends LitElement {
}
static styles = css`
ha-textfield,
ha-input,
ha-select {
display: block;
margin-bottom: 16px;
--ha-input-padding-bottom: 0;
}
.weekdays {
margin-bottom: 16px;
}
ha-textfield:last-child,
ha-input:last-child,
ha-select:last-child,
.weekdays:last-child {
margin-bottom: 0;
@@ -65,6 +65,9 @@ const processAreasForClimate = (
if (temperatureEntityId && hass.states[temperatureEntityId]) {
areaCards.push({
...computeTileCard(temperatureEntityId),
name:
hass.localize("component.sensor.entity_component.temperature.name") ||
"Temperature",
features: [{ type: "trend-graph" }],
});
}
@@ -73,6 +76,9 @@ const processAreasForClimate = (
if (humidityEntityId && hass.states[humidityEntityId]) {
areaCards.push({
...computeTileCard(humidityEntityId),
name:
hass.localize("component.sensor.entity_component.humidity.name") ||
"Humidity",
features: [{ type: "trend-graph" }],
});
}
@@ -1038,6 +1038,7 @@ class SupervisorAppInfo extends LitElement {
}
private _updateComplete() {
this._scheduleDataUpdate();
const eventdata = {
success: true,
response: undefined,
@@ -1059,11 +1060,16 @@ class SupervisorAppInfo extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
showAlertDialog(this, {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.apps.dashboard.action_error.install"
),
text: extractApiErrorMessage(err),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize(
"ui.panel.config.apps.dashboard.action_error.view_supervisor_logs"
),
cancel: () => navigate("/config/logs?provider=supervisor"),
});
}
button.progress = false;
@@ -6,14 +6,14 @@ import {
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../../../../common/url/search-params";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import "../../../logs/error-log-card";
import "../../../../../components/search-input";
import { extractSearchParam } from "../../../../../common/url/search-params";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
@customElement("supervisor-app-log-tab")
class SupervisorAppLogDashboard extends LitElement {
@@ -29,12 +29,12 @@ class SupervisorAppLogDashboard extends LitElement {
}
return html`
<div class="search">
<search-input
@value-changed=${this._filterChanged}
.hass=${this.hass}
.filter=${this._filter}
.label=${this.hass.localize("ui.panel.config.logs.search")}
></search-input>
<ha-input-search
appearance="outlined"
@input=${this._filterChanged}
.value=${this._filter}
.placeholder=${this.hass.localize("ui.panel.config.logs.search")}
></ha-input-search>
</div>
<div class="content">
<error-log-card
@@ -48,8 +48,8 @@ class SupervisorAppLogDashboard extends LitElement {
`;
}
private async _filterChanged(ev) {
this._filter = ev.detail.value;
private async _filterChanged(ev: InputEvent) {
this._filter = (ev.target as HTMLInputElement).value;
}
static get styles(): CSSResultGroup {
@@ -66,10 +66,10 @@ class SupervisorAppLogDashboard extends LitElement {
top: 0;
z-index: 2;
}
search-input {
display: block;
--mdc-text-field-fill-color: var(--sidebar-background-color);
--mdc-text-field-idle-line-color: var(--divider-color);
ha-input-search {
padding: var(--ha-space-3) var(--ha-space-2);
background: var(--sidebar-background-color);
border-bottom: 1px solid var(--divider-color);
}
@media all and (max-width: 870px) {
:host {
@@ -9,7 +9,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { goBack, navigate } from "../../../common/navigate";
import { extractSearchParam } from "../../../common/url/search-params";
import type { HassioAddonDetails } from "../../../data/hassio/addon";
import { fetchHassioAddonInfo } from "../../../data/hassio/addon";
@@ -234,7 +234,7 @@ class HaConfigAppDashboard extends LitElement {
if (path === "uninstall") {
// Navigate back to installed apps after uninstall
window.history.back();
goBack(this._fromStore ? "/config/apps/available" : "/config/apps");
} else {
// Reload app info
await this._loadAddon();
@@ -9,7 +9,7 @@ import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/search-input";
import "../../../components/input/ha-input-search";
import type {
HassioAddonRepository,
HassioAddonsInfo,
@@ -143,11 +143,11 @@ export class HaConfigAppsAvailable extends LitElement {
? html`<hass-loading-screen no-toolbar></hass-loading-screen>`
: html`
<div class="search">
<search-input
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._filterChanged}
></search-input>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._filterChanged}
></ha-input-search>
</div>
${repos}
@@ -260,8 +260,8 @@ export class HaConfigAppsAvailable extends LitElement {
}
};
private _filterChanged(e) {
this._filter = e.detail.value;
private _filterChanged(e: InputEvent) {
this._filter = (e.target as HTMLInputElement).value;
}
static styles = css`
@@ -278,10 +278,10 @@ export class HaConfigAppsAvailable extends LitElement {
top: 0;
z-index: 2;
}
search-input {
display: block;
--mdc-text-field-fill-color: var(--sidebar-background-color);
--mdc-text-field-idle-line-color: var(--divider-color);
ha-input-search {
padding: var(--ha-space-3) var(--ha-space-2);
background: var(--sidebar-background-color);
border-bottom: 1px solid var(--divider-color);
}
`;
}

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