Compare commits

..

182 Commits

Author SHA1 Message Date
Bram Kragten
d664ab6836 Bumped version to 20260325.1 2026-03-26 17:08:11 +01:00
Bram Kragten
a6c4184054 Replace ua-parser-js with simple regexs (#30355) 2026-03-26 17:07:45 +01:00
karwosts
cb6985eb7c Stabilize map colors (#30354) 2026-03-26 17:07:44 +01:00
Bram Kragten
d466ab63bd 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 17:07:40 +01:00
Paul Bottein
1132cdb364 Replace computeLovelaceEntityName with hass.formatEntityName (#30351) 2026-03-26 17:07:39 +01:00
Paul Bottein
0f9d48a03d 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 17:07:38 +01:00
Paul Bottein
7e085d9b08 Fix stack card scrollbar clipping box-shadows (#30346)
* Fix stack card scrollbar clipping box-shadows

* Remove grid options

* Remove scrollbar
2026-03-26 17:07:37 +01:00
Timothy
1a62c7296c Set tap highlight color to transparent for button (#30340) 2026-03-26 17:07:36 +01:00
Petar Petrov
be1921229c Fix energy pie chart legend showing raw data instead of formatted values (#30339) 2026-03-26 17:07:34 +01:00
Paul Bottein
640558ad35 Add composed/text mode toggle to entity name picker (#30337) 2026-03-26 17:07:33 +01:00
sir-Unknown
99636c9719 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 17:07:32 +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 Timson
3c012c30ac Migrate ha-toast to wa-popup instead of wa-popover (#30327) 2026-03-25 16:51:09 +01:00
Paul Bottein
84d234a330 Add support for climate swing horizontal mode in get_states (#30326) 2026-03-25 16:04:49 +01:00
Wendelin
a12543fe74 Add ha-input-docs (#30315)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-25 15:46:28 +01:00
karwosts
cc53f977a2 Fix trend and sensor graph when no history (#30323) 2026-03-25 15:37:28 +01:00
Paul Bottein
71541625d7 Add support for infrared domain (#30321) 2026-03-25 13:43:50 +00:00
Norbert Rittel
43da700ccc Clarify "wait_for_triggers" summary to reflect the OR condition (#30320) 2026-03-25 13:41:23 +00:00
Wendelin
efbbdbf3e8 Replace search-input-outlined with ha-input-search (#30319) 2026-03-25 14:33:55 +01:00
Bram Kragten
eee6f79639 Add mode option to numeric threshold selector (#30311) 2026-03-25 14:04:21 +01:00
Wendelin
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
pefia
2724087290 Fix cast manager listener unsubscribe behavior (#30307) 2026-03-25 10:49:09 +01:00
Wendelin
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
Abílio Costa
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 <MindFreeze@users.noreply.github.com>
2026-03-25 09:16:26 +00:00
Paul Bottein
15b1df5a58 Add light toggle button to home area view (#30301) 2026-03-25 09:53:20 +01:00
Paul Bottein
8222d9796c Show related entities warning when deleting helpers (#30302) 2026-03-25 09:52:19 +01:00
Wendelin
3337b414d7 Fix copy button in dev tools (#30313)
Fix copy dev tools entity id
2026-03-25 09:48:58 +01:00
Alex Gustafsson
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
karwosts
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 <MindFreeze@users.noreply.github.com>

* Apply suggestion from @MindFreeze

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

* 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 <petry2christian@gmail.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-25 08:45:10 +02:00
karwosts
0327b02d0b Fix clearing device class in entity registry (#30303) 2026-03-25 08:21:48 +02:00
renovate[bot]
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
Paul Bottein
89755f274d Refactor lovelace view lifecycle to avoid unnecessary DOM rebuilds (#30101)
* Refactor lovelace view lifecycle to avoid unnecessary DOM rebuilds

- Remove `force` flag from `hui-root` that was clearing the entire view
  cache and destroying all cached view DOM on any config change. Views
  now receive updated lovelace in place and handle config changes
  internally.
- Add `_cleanupViewCache` to remove stale cache entries when views are
  added, removed, or reordered.
- Remove `@ll-rebuild` handler from `hui-root`. Cards and badges already
  handle `ll-rebuild` via their `hui-card`/`hui-badge` wrappers. Sections
  now always stop propagation and rebuild locally.
- Add `deepEqual` guard in `hui-view._setConfig` and
  `hui-section._initializeConfig` to skip re-rendering when strategy
  regeneration produces an identical config.
- Simplify `hui-view` refresh flow: remove `_refreshConfig`,
  `_rendered` flag, `strategy-config-changed` event, and
  connected/disconnected callbacks. Registry changes now debounce
  directly into `_initializeConfig`.
- Fix `isStrategy` check in `hui-view._initializeConfig` to use the raw
  config (before strategy expansion) rather than the generated config.

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

* Remove unused type

* Improve viewCache cleanup

* clean up

* Handle custom view re-creation

* Fix custom view loading

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:41:30 +01:00
Wendelin
6ea15f507a Fix possible undefined errors in transformer functions (#30299) 2026-03-24 17:37:24 +01:00
Wendelin
c506fa8990 Remove unused ha-textfields (#30296) 2026-03-24 15:45:47 +01:00
Paul Bottein
9470863808 Improve sections view spacing and heading card grid options (#30295)
* Improve sections view spacing and heading card grid options

* Remove extra margin in heading section

* Update src/panels/lovelace/views/hui-sections-view.ts

Good catch !

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-24 15:26:05 +02:00
Wendelin
4bdac1f385 Migrate form/selector components ha-textfield to ha-input (#30294)
* Migrate ha-textfield to ha-input across form components and update related logic

* Apply suggestions from code review

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-24 13:18:09 +00:00
Aidan Timson
5bbfa36228 Support more-info-view query param (#30282)
* Support more-info-view query param

* Remove unintended subview logic and add details view

* Reset childView
2026-03-24 11:26:08 +02:00
Bram Kragten
a8070b322c Add numeric threshold selector (#30284)
* wip

* add numeric threshold selector

* clean up

* review optimize
2026-03-24 10:59:07 +02:00
Jan-Philipp Benecke
9cbc44123e Enhance delete entity confirmation dialog with detailed information (#30293) 2026-03-24 09:43:46 +01:00
Wendelin
c8f4c892f9 Migrate ha-multi-textfield, ha-selector-text to ha-input and update to use new input components (#30280)
* Migrate ha-multi-textfield to ha-input-multi and update ha-selector-text to use new input components

* Review

* Update src/components/input/ha-input-multi.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-24 07:04:39 +00:00
Simon Lamon
40b9f9dccb Gauge card improvements (#30149)
* Gauge card improvements

* Decrease stroke width

* Feedback

* Remove show more, move text to bottom

* Remove _handleMoreInfo method from hui-gauge-card

Removed the _handleMoreInfo method from hui-gauge-card.

* Remove unused fireEvent import from hui-gauge-card

Removed unused import for fireEvent.

* Fix up padding

* Better alignment from previous card

* Decrease padding

* Apply suggestions from code review

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-24 06:56:29 +00:00
Aidan Timson
823c222a55 Fix app info descriptions and metrics (#30287)
* Improve app info descriptions

* Space tokens on rest of files

* Don't display empty value
2026-03-23 18:36:29 +01:00
Paul Bottein
02acd2996c Allow boolean option to section background (#30289) 2026-03-23 18:32:53 +01:00
renovate[bot]
c462fc0639 Update formatjs monorepo (#30256)
* Update formatjs monorepo

* Fixup convert locale script

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-03-23 17:46:08 +01:00
renovate[bot]
903553dab9 Update dependency marked to v17.0.5 (#30286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 16:12:59 +00:00
Paul Bottein
25a1c14523 Refactor light color favorites card feature and button (#30281) 2026-03-23 16:29:48 +01:00
karwosts
f03eee6cb2 Fix select-entity-row timeout (#30249)
* Fix select-entity-row timeout

* fix possible undefined exception
2026-03-23 16:07:50 +02:00
Bram Kragten
a2a38e1da7 Bumped version to 20260312.1 2026-03-23 12:40:43 +01:00
Petar Petrov
88c063ba2a Fix hasReturnToGrid only checking first grid source in energy distribution card (#30273) 2026-03-23 12:39:36 +01:00
Tom Carpenter
984b50bac7 Fix water/gas total badge unit when sensor value is zero (#30279)
Fix water/gas badge when rate is 0

If there is only one flow rate sensor, and it has a value <=0, the badge would be displayed without a unit because the sensor gets skipped.

Instead of skipping <=0 values, continue to process sensor, but clamp the value to a minimum of 0. This ensures for single sensors we have a unit, and for multiple sensors the chosen unit is consistent even if the first sensor value drops to zero.
2026-03-23 13:36:15 +02:00
Wendelin
09e4355451 Migrate copy-textfield to input-copy (#30276) 2026-03-23 10:59:52 +00:00
Petar Petrov
7ee76538ae Fix trace tree left side unreachable on mobile (#30277) 2026-03-23 09:53:33 +00:00
Tom Carpenter
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 <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-23 10:07:15 +01:00
Wendelin
10e8c2a148 Fix copy-to-clipboard in unsecure context (#30204) 2026-03-23 10:07:15 +01:00
Qusai Ismael
e1a8616ab0 Fix missing conversation language picker in new pipeline dialog (#30194) 2026-03-23 10:07:14 +01:00
karwosts
ccdd71dd64 Fix tag dialog (#30191) 2026-03-23 10:07:13 +01:00
Petar Petrov
d3e1d55686 Fix negative monetary values displayed as positive (#30178) 2026-03-23 10:07:12 +01:00
Tom Carpenter
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ørensen
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 Timson
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ørensen
22c0035e60 Fix formatting of ha-switch in cloud remote preferences panel (#30143) 2026-03-23 10:07:08 +01:00
Petar Petrov
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
Maarten Lakerveld
3bbc3403d6 Validate external and internal URL on network tab (#30267) 2026-03-23 09:01:05 +00:00
Petar Petrov
9979bb13ea Fix hasReturnToGrid only checking first grid source in energy distribution card (#30273) 2026-03-23 08:55:22 +00:00
renovate[bot]
8ac831679d Update dependency @rsdoctor/rspack-plugin to v1.5.5 (#30275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 08:46:07 +00:00
karwosts
db05b07997 Use form instead of schema for element-sub-editor (#30210)
* Use form instead of schema for element-sub-editor

* fix after merge dev
2026-03-23 10:28:07 +02:00
Tom Carpenter
dba8cefa67 Fix statistics graph card units when using energy collections. (#30263)
* Use same now() time for all entity state values

Otherwise if there is a slight time lag while each statistic is processed, some of the points go missing from the tooltip. There end up being to very closely spaced time points for each different entity.

* Fix initial statistics graph card metadata loading

When using energy collection mode, the metadata for statistic entities was not always being loaded when the charts were first created. This could be seen in the graph units when they had differing unit (e.g. kW and W mixed).
2026-03-23 10:22:23 +02:00
Maarten Lakerveld
6935c55c3c Fix validation hint styling for ha-input (#30266) 2026-03-23 06:52:59 +00:00
renovate[bot]
635a1185a3 Update Yarn to v4.13.0 (#30258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 07:16:43 +01:00
renovate[bot]
585c894c5a Update dependency tar to v7.5.12 (#30269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 07:16:04 +01:00
renovate[bot]
f9d052a818 Update dependency jsdom to v29.0.1 (#30268)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 07:15:49 +01:00
dependabot[bot]
a29132441d Bump release-drafter/release-drafter from 7.0.0 to 7.1.1 (#30270)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.0.0 to 7.1.1.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](3a7fb5c85b...139054aeaa)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 07:15:32 +01:00
dependabot[bot]
479d52bf1d Bump actions/cache from 5.0.3 to 5.0.4 (#30271)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.3 to 5.0.4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](cdf6c1fa76...668228422a)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 07:15:19 +01:00
dependabot[bot]
d96d78d6f6 Bump github/codeql-action from 4.32.6 to 4.34.1 (#30272)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.32.6 to 4.34.1.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](0d579ffd05...3869755554)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 07:15:00 +01:00
Timothy
f80cba341f Add new configuration for current assist current device external link (#29979)
* Add new configuration for current assist current device external link

* Adjust CSS

* Adjust naming
2026-03-22 10:47:05 +01:00
Petar Petrov
77ee966442 Propagate schema changes to existing form editor element (#30200)
* Re-create form editor when schema changes in hui-form-element-editor

When a parent component passes a new schema to hui-form-element-editor
(e.g. with updated disabled flags after an entity change), the internal
hui-form-editor was not rebuilt because loadConfigElement() short-circuits
once the config element exists. This meant dynamic schema changes were
silently ignored.

Override updated() to detect schema changes after initial load, tear down
the stale config element via unloadConfigElement(), and re-set the value
so the normal load path recreates the form with the current schema.

Fixes #29776

* Fix prettier formatting in hui-map-card.ts

Pre-existing formatting issue on dev branch.

* Propagate schema changes to existing form editor element

When the schema property on hui-form-element-editor changes (e.g. because
the selected entity changed and disabled flags need updating), directly
update the schema on the already-created hui-form-editor config element.

Previously, unloadConfigElement() was tried but caused a visible flash
(editor going blank then reappearing), and re-setting value with a spread
object was tried but deepEqual considers it unchanged, short-circuiting
the reload.

The fix is minimal: make _configElement protected in HuiElementEditor so
the subclass can reach it, then in the updated() hook push the new schema
directly onto the existing element — no teardown, no re-creation.
2026-03-22 10:40:31 +01:00
Niklas Wagner
2fec5a497e Add translation support for nested app configuration schemas (#30121) 2026-03-22 10:38:36 +01:00
Sergio
ed75d96d3c Clarify Matter iOS version requirement in add-device fallback (#30129)
Improve Matter add-device guidance for older iOS devices
2026-03-22 10:23:02 +01:00
balloob-travel
0fac47992b Update cloud promo styling in network settings (#30246)
Update cloud promo styling

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-03-22 08:17:09 +00:00
Wendelin
91a608c4c5 fix ha-input styles (#30225) 2026-03-22 09:13:59 +01:00
renovate[bot]
df61953ed4 Update dependency @rspack/core to v1.7.9 (#30243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 20:56:13 +01:00
dependabot[bot]
2cda46b4bb Bump flatted from 3.3.3 to 3.4.1 (#30241)
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.1.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.1)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-20 07:51:41 +02:00
renovate[bot]
037190a393 Update babel monorepo to v7.29.2 (#30238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 06:42:56 +01:00
renovate[bot]
ebe0154e32 Update dependency typescript-eslint to v8.57.1 (#30235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 06:42:37 +01:00
renovate[bot]
efa73067f6 Update dependency core-js to v3.49.0 (#30236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 06:42:20 +01:00
renovate[bot]
fdb40c9d01 Update dependency @babel/helper-define-polyfill-provider to v0.6.8 (#30239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 06:42:06 +01:00
Paul Bottein
5a7ddb4972 Add background color option to dashboard sections (#30228) 2026-03-19 16:55:21 +01:00
Aidan Timson
dbe46d3b3f Remove advanced mode usages for apps area (#30232) 2026-03-19 16:31:04 +01:00
Petar Petrov
eb43d85439 Convert energy panel to use a dashboard strategy (#30170)
* Convert energy panel to use a dashboard strategy

Move the view-selection logic from ha-panel-energy into a dedicated
energy dashboard strategy, consistent with how home/areas/map dashboards
work. The strategy decides which view strategies (electricity, gas,
water, power, overview) to show based on energy preferences.

Extract shared constants into a separate module to avoid circular
dependencies between the panel and view strategies.

* Remove unused energy collection constants from ha-panel-energy
2026-03-19 16:16:56 +01:00
Aidan Timson
1bbfb79ddb Sort disabled and ignored integrations by name, translate disabled domains (#30230)
* Sort Disabled and Ignored integrations by name

* Localise disabled integration domains
2026-03-19 17:03:30 +02:00
Wendelin
cf50db350f Temporarily disable "focus_element" implementation for iOS app (#30226) 2026-03-19 13:51:37 +00:00
Wendelin
e04a0ec7dc Prevent time-input to close dialogs, popups and bottom-sheets (#30227) 2026-03-19 13:47:06 +00:00
renovate[bot]
e08576a6dc Update dependency sinon to v21.0.3 (#30224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 13:40:50 +00:00
Petar Petrov
a7831f86ee Use --ha-box-shadow-l for footer (#30221) 2026-03-19 13:01:23 +01:00
Petar Petrov
c66e5b379b Show current entity value in history chart legend (#30222) 2026-03-19 11:16:47 +00:00
Aidan Timson
e819c30151 Hide behavior selector if no targets are populated (#30219) 2026-03-19 11:48:18 +01:00
Wendelin
e278e33375 date-range-picker with cally (#30193)
* date-range-picker with cally

* fix timePicker

* Review: backdrop transition

* fix comments

* Add formatCallyDateRange

* Refactor date formatting in date range picker and remove unused styles

* time-input without label

* review
2026-03-19 10:30:15 +00:00
renovate[bot]
8313be8e7e Update dependency @rsdoctor/rspack-plugin to v1.5.4 (#30220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 11:44:40 +02:00
Tom Carpenter
6d95a59ca0 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 <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-19 11:33:51 +02:00
Paul Bottein
a498ad3d06 Refactor device entities card to use Lit directive (#30138)
* Refactor device entities card to use Lit directive for entity rows

Replace the imperative pattern (shouldUpdate hack, _entityRows array,
_renderEntity pushing elements) with a declarative approach using a
reusable Lit directive and repeat for stable keying.

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

* Fix function name and padding issue

* Recreate on domain change, otherwise update

* Prettier

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:23:02 +02:00
renovate[bot]
c4a2229baa Update formatjs monorepo (#30215)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 08:20:34 +02:00
ildar170975
15245af52d Markdown card: add support of actions (#28951)
* add actions

* add actions

* add actions to MarkdownCardConfig

* add "actions_warning" for Markdown

* Add a "warning" label

* process a default handler if none action is defined

* check for config=undefined

* fix attributes

* prettier

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* add ripple

* Fix interactive and add missing import for ripple

* Fix translation

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-03-18 17:22:28 +00:00
Wendelin
c697735e46 Thread dashboard - replace fab with card (#30212)
Add UI to import Thread credentials
2026-03-18 17:47:39 +02:00
Paul Bottein
ddec792ae3 Add entity name alias toggle and drag-to-reorder aliases in voice settings (#30201)
* Add entity name alias toggle and drag-to-reorder aliases in voice settings

* Fix reorder

* Update src/panels/config/voice-assistants/entity-voice-settings.ts

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

* Use map instead of repeat

* Improve key

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-18 17:42:54 +02:00
Aidan Timson
cfd0e72609 Fix disabled entity ID in helper settings (#30213) 2026-03-18 15:42:19 +00:00
Aidan Timson
8a5bcd67ab Hide behavior selector for single target in triggers and conditions (labs) (#30145) 2026-03-18 16:26:34 +01:00
Paul Bottein
a794a80228 Add scrollbar support for cards with fixed grid row height (#30209)
* Add scrollbar support for cards with fixed grid row height

* Fix default sizing

* Add warning for card that doesn't support resizing well

* Remove not used explanation
2026-03-18 16:11:29 +01:00
Bram Kragten
41ed7d2877 Add uom filter to entity selector (#30211) 2026-03-18 16:00:34 +01:00
Jan-Philipp Benecke
b0b86e7ba8 Gate blueprint search behind minimum blueprint amount (#30207)
* Gate blueprint search behind minimum blueprint amount

* Remove top padding from search
2026-03-18 15:10:49 +02:00
Paul Bottein
e67c4842d4 Add an auto height toggle in card layout editor (#30182)
* Add auto height toggle in grid card editor

* Improve toggle

* Prettier
2026-03-18 13:51:38 +01:00
Petar Petrov
d9c39640e0 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-18 13:00:17 +01:00
Wendelin
a8478ab346 Fix copy-to-clipboard in unsecure context (#30204) 2026-03-18 11:56:35 +00:00
Petar Petrov
3ac2434b6f Rescale Y-axis on chart zoom via custom AxisProxy filterMode (#30192)
* Rescale Y-axis on chart zoom via custom AxisProxy filterMode

Patch ECharts' AxisProxy.filterData to support a "boundaryFilter" mode
that keeps the nearest data point outside each zoom boundary while
filtering distant points. This lets ECharts natively rescale the Y-axis
to the visible data range without causing line gaps at the zoom edges.

* Add tests for ECharts AxisProxy patch internals

Verify that the ECharts internals our boundaryFilter patch relies on
still exist (filterData, getTargetSeriesModels on AxisProxy prototype),
and test the patch behavior: delegation for other filterModes, early
return for non-matching models, and correct boundary-preserving filtering.

* Update comment
2026-03-18 10:58:51 +00:00
Aidan Timson
f2f1044992 Format map card (#30202) 2026-03-18 12:52:06 +02:00
ildar170975
53bc66883a Map card editor: add more options (#29759)
* optimize ThemeMode

* add more map card options

* fix a type for label_mode

* enhance EntitySelectorFilter

* fix a type for label_mode

* fix classes for Map card

* add "required" flag

* add more options

* remove leading space

* simplify a bit

* Update src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts

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

* fix labels & simplify _deleteLabelModeOptions()

* move translations to generic

* resolving conflicts

* revert

* remove filterFunc

* use include_entities

* add a missing line

* prettier

* prettier

* Update src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts

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

* simplify _computeLabelCallback()

* Update src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts

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

* set location entiites in firstUpdated()

* add "disabled"

* disable "color" opton for a "zone" entity

* Update src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts

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

* Update src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts

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

* move MapCardMarkerLabelMode to ha-map

* move MapCardMarkerLabelMode to ha-map

* move MapCardMarkerLabelMode to ha-map

* move MAP_CARD_MARKER_LABEL_MODES to ha-map

* little fix

* fix import

* typo in import

* linter

* linter

* rename _disabledOptions -> _shouldDisableOptions

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-18 09:08:45 +00:00
Aidan Timson
d795bd1f61 Valve favorites (#30190)
* Setup valve favorites

* Setup card feature

* Fix styles, match covers

* Merge numeric favorites card features

* Merge favorites handlers in more info favorites

* Use correct key

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

* Add translation

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-18 11:03:59 +02:00
Norbert Rittel
869e1d32b3 Replace remaining occurrences of "grid return" with "grid export" (#30199) 2026-03-18 10:53:31 +02:00
Aidan Timson
3370bfa9dd Move Device and Entity triggers and conditions to Generic group (#30185)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 09:07:27 +01:00
Paul Bottein
b1921d1b66 Use explicit default name in entity name picker and lovelace cards (#30189) 2026-03-18 09:02:04 +01:00
renovate[bot]
c2a2b382e9 Update dependency jsdom to v29 (#30198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 08:57:11 +01:00
Qusai Ismael
7d95c2b6cb Fix missing conversation language picker in new pipeline dialog (#30194) 2026-03-18 08:56:04 +01:00
Allen Porter
67536a8a64 Display thinking steps and tool calling in the assist dialog (#29680)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-18 08:47:17 +01:00
Joe Julian
3d89ad4f91 calendar: move to "today" daily at midnight (#30177)
calendar: move to today daily at midnight
2026-03-18 08:50:42 +02:00
Paul Bottein
36e08367d9 Use domain-specific label for Edit button in more info dialog (#30195) 2026-03-17 19:07:14 +01:00
Bram Kragten
9c4aacdb1f Bumped version to 20260312.0 2026-03-12 22:08:30 +01:00
Paul Bottein
3feb40a8f4 Add token for brands url in hassUrl helper (#30111) 2026-03-12 22:07:51 +01:00
Tom Carpenter
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 Timson
ee77619da3 Fix code editor autocomplete using wa popup (#30081) 2026-03-12 22:07:49 +01:00
Petar Petrov
cfa8eb5370 Fix hasReturn check to scan all grid sources in energy view strategy (#30062) 2026-03-12 22:07:48 +01:00
Tom Carpenter
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 Carpenter
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 Petrov
c790d2356c Add back energy distribution card to electricity tab (#30049) 2026-03-12 22:07:44 +01:00
Yosi Levy
f24c009dd7 RTL textfield fixes for quick search (#30013)
textfield fixes for quick search
2026-03-12 22:07:44 +01:00
Petar Petrov
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
Paul Bottein
1a6d46a7ff Refactor tooltip CSS tokens to use ha- prefix (#29978)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-03-12 22:07:42 +01:00
Petar Petrov
b286b07cfd Fix sensor card graph time axis not progressing when value is unchanged (#29976) 2026-03-12 22:07:41 +01:00
Paul Bottein
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
Wendelin
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 Bottein
1b8211db6d Align heading button font-size with other heading entity badge (#29958) 2026-03-04 12:42:22 +01:00
Aidan Timson
2b2bb77a2b Fix copy to clipboard for wa dialogs (#29951)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-03-04 12:42:21 +01:00
Wendelin
64749350ef ha-bottom-sheet reduce motion support (#29950) 2026-03-04 12:42:20 +01:00
Paul Bottein
043d4eed85 Add label for toggle button in area strategy (#29949) 2026-03-04 12:42:19 +01:00
Paul Bottein
2f2e64bb1d Use max width for dashboard footer (#29947) 2026-03-04 12:39:18 +01:00
Petar Petrov
b74b02c09f Use net battery power in power sankey card (#29940) 2026-03-04 12:39:17 +01:00
Wendelin
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 Bottein
465c10b945 Fix updates, discovered devices and repairs cards flickering (#29935) 2026-03-02 17:07:10 +01:00
Paul Bottein
457c51cf58 Fix sidebar not closing when reduced motion is enabled (#29934) 2026-03-02 17:07:09 +01:00
Wendelin
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 Timson
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
Wendelin
67ccfa0f6e Add error translation for loading energy preferences (#29924) 2026-03-02 17:07:05 +01:00
karwosts
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 Bottein
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 Petrov
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 Petrov
7b8884f0fd Fix sensor card graph not updating when value is unchanged (#29889) 2026-03-02 17:06:57 +01:00
Petar Petrov
aff1fedc9d Fix monetary device class state display with non-ISO 4217 currency symbols (#29887) 2026-03-02 17:06:56 +01:00
Petar Petrov
8f5059c24a Fix energy compare tooltip showing wrong year (#29885) 2026-03-02 17:06:54 +01:00
Aidan Timson
1e72ad1411 Code editor fullscreen in dialogs (#29882)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-03-02 17:06:53 +01:00
Paul Bottein
c9f96bbe69 Add render icon property to ha-control-select-menu (#29881) 2026-03-02 17:06:52 +01:00
Aidan Timson
616c3d4657 Use large width on system log dialogs (#29879) 2026-03-02 17:06:50 +01:00
Robert Resch
b1ceece224 Revert "Add vacuum mapping not configured issue" (#29876) 2026-03-02 17:06:49 +01:00
Brandon Chen
d695c4c845 Fix YAML content invisible in dark mode for conversation debug result… (#29874) 2026-03-02 17:06:48 +01:00
Petar Petrov
fdbeb12622 Migrate Energy date selector to new footer (#29867) 2026-03-02 17:04:41 +01:00
Aidan Timson
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 <mail@bramkragten.nl>

* Memoize

* Rename

* Fix

* Format audits in normal mode

* Refactor, dont pass hass

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-03-02 17:04:40 +01:00
Matthias Alphart
519d3d0e53 Fix data-table content bottom margin (#29805)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
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 Bottein
2685a007e7 Fix scrollbar in 2026.3 (#29865) 2026-02-26 16:55:15 +01:00
Aidan Timson
9ca1cfbf4a Add thread configuration my link (#29861) 2026-02-26 16:55:14 +01:00
Aidan Timson
0793af6846 Add matter configuration my link (#29859) 2026-02-26 16:55:13 +01:00
Wendelin
bb7f441d8d Fix quick search icon size (#29858) 2026-02-26 16:55:12 +01:00
Aidan Timson
2813ed7938 Add missing theming variable support to dialog and bottom sheet (#29857) 2026-02-26 16:55:10 +01:00
Wendelin
9ebfa4029b Fix ha-icon-button-toggle selected style (#29856) 2026-02-26 16:55:10 +01:00
Aidan Timson
6190ba18ea Fix esc closing dialogs with prevent scrim close (#29851) 2026-02-26 16:55:09 +01:00
Petar Petrov
81feea1109 Dynamically calculate the date range picker's vertical opening direction (#29850) 2026-02-26 16:55:08 +01:00
Wendelin
be430931cc Fix protocols dashboards fab padding (#29847) 2026-02-26 16:55:07 +01:00
Petar Petrov
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
310 changed files with 11125 additions and 7091 deletions

View File

@@ -37,7 +37,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
node_modules/.cache/prettier

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.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@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/autobuild@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1

View File

@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@3a7fb5c85b80b1dda66e1ccb94009adbbd32fce3 # v7.0.0
- uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.nvmrc
View File

@@ -1 +1 @@
24.14.0
24.14.1

File diff suppressed because one or more lines are too long

940
.yarn/releases/yarn-4.13.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -8,4 +8,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.12.0.cjs
yarnPath: .yarn/releases/yarn-4.13.0.cjs

View File

@@ -40,18 +40,24 @@ const convertToJSON = async (
throw e;
}
// Convert to JSON
const parts = localeData.split("} else {");
const firstBlock = parts[0];
const obj = INTL_POLYFILLS[pkg];
const dataRegex = new RegExp(
`Intl\\.${obj}\\.${addFunc}\\((?<data>.*)\\)`,
"s"
);
localeData = localeData.match(dataRegex)?.groups?.data;
localeData = firstBlock.match(dataRegex)?.groups?.data;
if (!localeData) {
throw Error(`Failed to extract data for language ${lang} from ${pkg}`);
}
// Parse to validate JSON, then stringify to minify
localeData = JSON.stringify(JSON.parse(localeData));
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
try {
localeData = JSON.stringify(JSON.parse(localeData));
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
} catch (e) {
throw Error(`Failed to parse JSON for language ${lang} from ${pkg}: ${e}`);
}
};
gulp.task("clean-locale-data", async () => deleteSync([outDir]));

View File

@@ -480,6 +480,12 @@ const SCHEMAS: {
},
{ type: "string", name: "path", default: "/" },
{ type: "boolean", name: "ssl", default: false },
{
type: "string",
name: "comments",
default: "disabled field",
disabled: true,
},
],
},
];

View File

@@ -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="you@example.com"></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.

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="you@example.com"
></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;
}
}

View File

@@ -26,7 +26,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.28.6",
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
@@ -37,15 +37,15 @@
"@codemirror/view": "6.40.0",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.2.5",
"@formatjs/intl-displaynames": "7.2.2",
"@formatjs/intl-durationformat": "0.10.1",
"@formatjs/intl-getcanonicallocales": "3.2.1",
"@formatjs/intl-listformat": "8.2.2",
"@formatjs/intl-locale": "5.2.1",
"@formatjs/intl-numberformat": "9.2.3",
"@formatjs/intl-pluralrules": "6.2.3",
"@formatjs/intl-relativetimeformat": "12.2.3",
"@formatjs/intl-datetimeformat": "7.3.1",
"@formatjs/intl-displaynames": "7.3.1",
"@formatjs/intl-durationformat": "0.10.3",
"@formatjs/intl-getcanonicallocales": "3.2.2",
"@formatjs/intl-listformat": "8.3.1",
"@formatjs/intl-locale": "5.3.1",
"@formatjs/intl-numberformat": "9.3.1",
"@formatjs/intl-pluralrules": "6.3.1",
"@formatjs/intl-relativetimeformat": "12.3.1",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -87,14 +87,13 @@
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.1",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
"core-js": "3.48.0",
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.1.0",
@@ -109,7 +108,7 @@
"hls.js": "1.6.15",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.1.2",
"intl-messageformat": "11.2.0",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -117,7 +116,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.4",
"marked": "17.0.5",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -130,9 +129,6 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.9",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
@@ -144,17 +140,17 @@
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.7",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.0",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.0",
"@html-eslint/eslint-plugin": "0.58.1",
"@lokalise/node-api": "15.6.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.2",
"@rspack/core": "1.7.8",
"@rsdoctor/rspack-plugin": "1.5.5",
"@rspack/core": "1.7.9",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -172,7 +168,6 @@
"@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",
"babel-loader": "10.1.1",
@@ -197,7 +192,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "28.1.0",
"jsdom": "29.0.1",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
@@ -208,12 +203,12 @@
"prettier": "3.8.1",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.0.2",
"tar": "7.5.11",
"sinon": "21.0.3",
"tar": "7.5.12",
"terser-webpack-plugin": "5.4.0",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.57.0",
"typescript-eslint": "8.57.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.0",
"webpack-stats-plugin": "1.1.3",
@@ -231,8 +226,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.12.0",
"packageManager": "yarn@4.13.0",
"volta": {
"node": "24.14.0"
"node": "24.14.1"
}
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260128.0"
version = "20260325.1"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

@@ -24,11 +24,6 @@
"extends": ["monorepo:material-components-web"],
"enabled": false
},
{
"description": "Vue is only used by date range which is only v2",
"matchPackageNames": ["vue"],
"allowedVersions": "< 3"
},
{
"description": "Group MDI packages",
"groupName": "Material Design Icons",

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);
}
};
}

View File

@@ -1,17 +1,17 @@
import {
addDays,
subHours,
endOfDay,
endOfMonth,
endOfQuarter,
endOfWeek,
endOfYear,
startOfDay,
startOfMonth,
startOfQuarter,
startOfWeek,
startOfYear,
startOfQuarter,
endOfQuarter,
subDays,
subHours,
subMonths,
} from "date-fns";
import type { HomeAssistant } from "../../types";
@@ -33,88 +33,89 @@ export type DateRange =
| "now-24h";
export const calcDateRange = (
hass: HomeAssistant,
locale: HomeAssistant["locale"],
hassConfig: HomeAssistant["config"],
range: DateRange
): [Date, Date] => {
const today = new Date();
const weekStartsOn = firstWeekdayIndex(hass.locale);
const weekStartsOn = firstWeekdayIndex(locale);
switch (range) {
case "today":
return [
calcDate(today, startOfDay, hass.locale, hass.config, {
calcDate(today, startOfDay, locale, hassConfig, {
weekStartsOn,
}),
calcDate(today, endOfDay, hass.locale, hass.config, {
calcDate(today, endOfDay, locale, hassConfig, {
weekStartsOn,
}),
];
case "yesterday":
return [
calcDate(addDays(today, -1), startOfDay, hass.locale, hass.config, {
calcDate(addDays(today, -1), startOfDay, locale, hassConfig, {
weekStartsOn,
}),
calcDate(addDays(today, -1), endOfDay, hass.locale, hass.config, {
calcDate(addDays(today, -1), endOfDay, locale, hassConfig, {
weekStartsOn,
}),
];
case "this_week":
return [
calcDate(today, startOfWeek, hass.locale, hass.config, {
calcDate(today, startOfWeek, locale, hassConfig, {
weekStartsOn,
}),
calcDate(today, endOfWeek, hass.locale, hass.config, {
calcDate(today, endOfWeek, locale, hassConfig, {
weekStartsOn,
}),
];
case "this_month":
return [
calcDate(today, startOfMonth, hass.locale, hass.config),
calcDate(today, endOfMonth, hass.locale, hass.config),
calcDate(today, startOfMonth, locale, hassConfig),
calcDate(today, endOfMonth, locale, hassConfig),
];
case "this_quarter":
return [
calcDate(today, startOfQuarter, hass.locale, hass.config),
calcDate(today, endOfQuarter, hass.locale, hass.config),
calcDate(today, startOfQuarter, locale, hassConfig),
calcDate(today, endOfQuarter, locale, hassConfig),
];
case "this_year":
return [
calcDate(today, startOfYear, hass.locale, hass.config),
calcDate(today, endOfYear, hass.locale, hass.config),
calcDate(today, startOfYear, locale, hassConfig),
calcDate(today, endOfYear, locale, hassConfig),
];
case "now-7d":
return [
calcDate(today, subDays, hass.locale, hass.config, 7),
calcDate(today, subDays, hass.locale, hass.config, 0),
calcDate(today, subDays, locale, hassConfig, 7),
calcDate(today, subDays, locale, hassConfig, 0),
];
case "now-30d":
return [
calcDate(today, subDays, hass.locale, hass.config, 30),
calcDate(today, subDays, hass.locale, hass.config, 0),
calcDate(today, subDays, locale, hassConfig, 30),
calcDate(today, subDays, locale, hassConfig, 0),
];
case "now-12m":
return [
calcDate(
today,
(date) => subMonths(startOfMonth(date), 11),
hass.locale,
hass.config
locale,
hassConfig
),
calcDate(today, endOfMonth, hass.locale, hass.config),
calcDate(today, endOfMonth, locale, hassConfig),
];
case "now-1h":
return [
calcDate(today, subHours, hass.locale, hass.config, 1),
calcDate(today, subHours, hass.locale, hass.config, 0),
calcDate(today, subHours, locale, hassConfig, 1),
calcDate(today, subHours, locale, hassConfig, 0),
];
case "now-12h":
return [
calcDate(today, subHours, hass.locale, hass.config, 12),
calcDate(today, subHours, hass.locale, hass.config, 0),
calcDate(today, subHours, locale, hassConfig, 12),
calcDate(today, subHours, locale, hassConfig, 0),
];
case "now-24h":
return [
calcDate(today, subHours, hass.locale, hass.config, 24),
calcDate(today, subHours, hass.locale, hass.config, 0),
calcDate(today, subHours, locale, hassConfig, 24),
calcDate(today, subHours, locale, hassConfig, 0),
];
}
return [today, today];

View File

@@ -261,3 +261,36 @@ const formatDateWeekdayShortDateMem = memoizeOne(
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
/**
* Format a date as YYYY-MM-DD. Uses "en-CA" because it's the only
* Intl locale that natively outputs ISO 8601 date format.
* Locale/config are only used to resolve the time zone.
*/
export const formatISODateOnly = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => {
const timeZone = resolveTimeZone(locale.time_zone, config.time_zone);
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone,
});
return formatter.format(dateObj);
};
// 2026-08-10/2026-08-15
export const formatCallyDateRange = (
start: Date,
end: Date,
locale: FrontendLocaleData,
config: HassConfig
) => {
const startDate = formatISODateOnly(start, locale, config);
const endDate = formatISODateOnly(end, locale, config);
return `${startDate}/${endDate}`;
};

View File

@@ -29,14 +29,18 @@ 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;
}
let items = ensureArray(name ?? DEFAULT_ENTITY_NAME);
const separator = options?.separator ?? DEFAULT_SEPARATOR;

View File

@@ -254,6 +254,7 @@ const computeStateToPartsFromEntityAttributes = (
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"scene",

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":

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;
}

View File

@@ -1,24 +1,5 @@
import { deepActiveElement } from "../dom/deep-active-element";
const getClipboardFallbackRoot = (): HTMLElement => {
const activeElement = deepActiveElement();
if (activeElement instanceof HTMLElement) {
let root: Node = activeElement.getRootNode();
let host: HTMLElement | null = null;
while (root instanceof ShadowRoot && root.host instanceof HTMLElement) {
host = root.host;
root = root.host.getRootNode();
}
if (host) {
return host;
}
}
return document.body;
};
export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
if (navigator.clipboard) {
try {
@@ -29,7 +10,7 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
}
}
const root = rootEl || getClipboardFallbackRoot();
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
const el = document.createElement("textarea");
el.value = str;

View File

@@ -44,6 +44,7 @@ export type CustomLegendOption = ECOption["legend"] & {
id?: string;
secondaryIds?: string[]; // Other dataset IDs that should be controlled by this legend item.
name: string;
value?: string; // Current value to display next to the name in the legend.
itemStyle?: Record<string, any>;
}[];
};
@@ -279,18 +280,23 @@ 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>
`;
@@ -333,12 +339,14 @@ export class HaChartBase extends LitElement {
let itemStyle: Record<string, any> = {};
let name = "";
let id = "";
let value = "";
if (typeof item === "string") {
name = item;
id = item;
} else {
name = item.name ?? "";
id = item.id ?? name;
value = item.value ?? "";
itemStyle = item.itemStyle ?? {};
}
const dataset =
@@ -365,6 +373,7 @@ export class HaChartBase extends LitElement {
})}
></div>
<div class="label">${name}</div>
${value ? html`<div class="value">${value}</div>` : nothing}
</li>`;
})}
${items.length > overflowLimit
@@ -578,7 +587,10 @@ export class HaChartBase extends LitElement {
id: "dataZoom",
type: "inside",
orient: "horizontal",
filterMode: "none",
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
// It rescales the Y-axis to the visible data while keeping one point
// just outside each boundary to avoid line gaps at the zoom edges.
filterMode: "boundaryFilter" as any,
xAxisIndex: 0,
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
@@ -1109,16 +1121,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,
@@ -1166,6 +1197,9 @@ export class HaChartBase extends LitElement {
.chart-legend.multiple-items li {
max-width: 220px;
}
.chart-legend.multiple-items li:has(.value) {
max-width: 300px;
}
.chart-legend .hidden {
color: var(--secondary-text-color);
}
@@ -1174,6 +1208,12 @@ export class HaChartBase extends LitElement {
white-space: nowrap;
overflow: hidden;
}
.chart-legend .value {
color: var(--secondary-text-color);
margin-inline-start: var(--ha-space-1);
flex-shrink: 0;
white-space: nowrap;
}
.chart-legend .bullet {
border-width: 1px;
border-style: solid;

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,
@@ -76,8 +78,20 @@ 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;
@@ -117,6 +131,9 @@ 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(
@@ -124,12 +141,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
this._physicsEnabled,
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 +184,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
...category,
icon: category.symbol,
})),
top: 8,
bottom: 8,
},
dataZoom: {
type: "inside",
@@ -175,13 +194,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 +276,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
},
},
emphasis: {
focus: isMobile ? "none" : "adjacency",
focus: hasHighlightedNodes ? "self" : isMobile ? "none" : "adjacency",
},
force: {
repulsion: [400, 600],
@@ -362,6 +424,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;

View File

@@ -239,7 +239,9 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("fitYData") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_visualMap") ||
changedProps.has("_yWidth")
changedProps.has("_yWidth") ||
(changedProps.has("hass") &&
this._hasEntityStatesChanged(changedProps.get("hass")))
) {
const rtl = computeRTL(this.hass);
let minYAxis: number | ((values: { min: number }) => number) | undefined =
@@ -296,6 +298,19 @@ export class StateHistoryChartLine extends LitElement {
legend: {
type: "custom",
show: this.showNames,
data: this._chartData
.map((d, i) => ({ dataset: d, entityId: this._entityIds[i] }))
.filter((item) => !(item.dataset as LineSeriesOption).areaStyle)
.map((item) => {
const stateObj = this.hass.states[item.entityId];
return {
id: item.dataset.id as string,
name: item.dataset.name as string,
value: stateObj
? this.hass.formatEntityState(stateObj)
: undefined,
};
}),
},
grid: {
top: 15,
@@ -316,6 +331,13 @@ export class StateHistoryChartLine extends LitElement {
}
}
private _hasEntityStatesChanged(oldHass: HomeAssistant): boolean {
return this._entityIds.some(
(entityId) =>
this.hass.states[entityId]?.state !== oldHass.states[entityId]?.state
);
}
private _generateData() {
let colorIndex = 0;
const computedStyles = getComputedStyle(this);

View File

@@ -27,6 +27,7 @@ import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
@@ -398,7 +399,31 @@ export class StatisticsChart extends LitElement {
endTime = new Date();
}
let unit: string | undefined | null;
// Check if we need to display most recent data. Allow 10m of leeway for "now",
// because stats are 5 minute aggregated.
// Use same now point for all statistics even if processing time means the
// state value is actually from a slightly later time. Otherwise the points
// end up separated slightly and disappear from the tooltips.
const now = new Date();
const displayCurrentState = now.getTime() - endTime.getTime() <= 600000;
// Try to determine chart unit if it has not already been set explicitly
if (!this.unit) {
let unit: string | undefined | null;
statisticsData.forEach(([statistic_id, _stats]) => {
const meta = statisticsMetaData?.[statistic_id];
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (unit === undefined) {
unit = statisticUnit;
} else if (unit !== null && unit !== statisticUnit) {
// Clear unit if not all statistics have same unit
unit = null;
}
});
if (unit) {
this.unit = unit;
}
}
const names = this.names || {};
statisticsData.forEach(([statistic_id, stats]) => {
@@ -408,18 +433,6 @@ export class StatisticsChart extends LitElement {
name = getStatisticLabel(this.hass, statistic_id, meta);
}
if (!this.unit) {
if (unit === undefined) {
unit = getDisplayUnit(this.hass, statistic_id, meta);
} else if (
unit !== null &&
unit !== getDisplayUnit(this.hass, statistic_id, meta)
) {
// Clear unit if not all statistics have same unit
unit = null;
}
}
// array containing [value1, value2, etc]
let prevValues: (number | null)[][] | null = null;
let prevEndTime: Date | undefined;
@@ -543,7 +556,7 @@ export class StatisticsChart extends LitElement {
(series as LineSeriesOption).areaStyle = undefined;
} else {
series.stackOrder = "seriesAsc";
if (drawBands && type === bandTop) {
if (type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
@@ -623,13 +636,14 @@ export class StatisticsChart extends LitElement {
});
}
// Append current state if viewing recent data
const now = new Date();
// allow 10m of leeway for "now", because stats are 5 minute aggregated
const isUpToNow = now.getTime() - endTime.getTime() <= 600000;
if (isUpToNow) {
// Skip external statistics (they have ":" in the ID)
if (!statistic_id.includes(":")) {
// Show current state if required, and units match (or are unknown)
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (
displayCurrentState &&
(!this.unit || !statisticUnit || this.unit === statisticUnit)
) {
// Skip external statistics
if (!isExternalStatistic(statistic_id)) {
const stateObj = this.hass.states[statistic_id];
if (stateObj) {
const currentValue = parseFloat(stateObj.state);
@@ -670,10 +684,6 @@ export class StatisticsChart extends LitElement {
Array.prototype.push.apply(legendData, statLegendData);
});
if (unit) {
this.unit = unit;
}
legendData.forEach(({ id, name, color, borderColor }) => {
// Add an empty series for the legend
totalDataSets.push({

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;

View File

@@ -0,0 +1,348 @@
import { TZDate } from "@date-fns/tz";
import { consume, type ContextType } from "@lit/context";
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 { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatDateMonth,
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { TimeZone } from "../../data/translation";
import type { ValueChangedEvent } from "../../types";
import type { HaBaseTimeInput } from "../ha-base-time-input";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-list";
import "../ha-list-item";
import "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
import { datePickerStyles, dateRangePickerStyles } from "./styles";
@customElement("date-range-picker")
export class DateRangePicker extends LitElement {
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
@property({ attribute: false }) public startDate?: Date;
@property({ attribute: false }) public endDate?: Date;
@property({ attribute: "time-picker", type: Boolean })
public timePicker = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@state()
@consume({ context: configContext, subscribe: true })
private hassConfig!: ContextType<typeof configContext>;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@state() private _timeValue = {
from: { hours: 0, minutes: 0 },
to: { hours: 23, minutes: 59 },
};
public connectedCallback() {
super.connectedCallback();
const date = this.startDate || new Date();
this._dateValue =
this.startDate && this.endDate
? formatCallyDateRange(
this.startDate,
this.endDate,
this.locale,
this.hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
if (this.timePicker && this.startDate && this.endDate) {
this._timeValue = {
from: {
hours: this.startDate.getHours(),
minutes: this.startDate.getMinutes(),
},
to: {
hours: this.endDate.getHours(),
minutes: this.endDate.getMinutes(),
},
};
}
}
render() {
return html`<div class="picker">
${this.ranges !== false && this.ranges
? html`<div class="date-range-ranges">
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
)}
</ha-list>
</div>`
: nothing}
<div class="range">
<calendar-range
.value=${this._dateValue}
.locale=${this.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this.locale)}
>
<ha-icon-button-prev
tabindex="-1"
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
.label=${this.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next
tabindex="-1"
slot="next"
></ha-icon-button-next>
<calendar-month></calendar-month>
</calendar-range>
${this.timePicker
? html`
<div class="times">
<ha-time-input
.value=${`${this._timeValue.from.hours}:${this._timeValue.from.minutes}`}
.locale=${this.locale}
@value-changed=${this._handleChangeTime}
.label=${this.localize(
"ui.components.date-range-picker.time_from"
)}
id="from"
placeholder-labels
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
.locale=${this.locale}
@value-changed=${this._handleChangeTime}
.label=${this.localize(
"ui.components.date-range-picker.time_to"
)}
id="to"
placeholder-labels
></ha-time-input>
</div>
`
: nothing}
</div>
</div>
<div class="footer">
<ha-button appearance="plain" @click=${this._cancel}
>${this.localize("ui.common.cancel")}</ha-button
>
<ha-button .disabled=${!this._dateValue} @click=${this._save}
>${this.localize("ui.components.date-range-picker.select")}</ha-button
>
</div>`;
}
private _focusToday() {
const date = new Date();
this._focusDate = formatISODateOnly(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
}
private _cancel() {
fireEvent(this, "cancel-date-picker");
}
private _save() {
if (!this._dateValue) {
return;
}
const dates = this._dateValue.split("/");
let startDate = new Date(`${dates[0]}T00:00:00`);
let endDate = new Date(`${dates[1]}T23:59:00`);
if (this.timePicker) {
startDate.setHours(this._timeValue.from.hours);
startDate.setMinutes(this._timeValue.from.minutes);
endDate.setHours(this._timeValue.to.hours);
endDate.setMinutes(this._timeValue.to.minutes);
startDate.setSeconds(0);
startDate.setMilliseconds(0);
endDate.setSeconds(0);
endDate.setMilliseconds(0);
if (endDate <= startDate) {
endDate.setDate(startDate.getDate() + 1);
}
}
if (this.locale.time_zone === TimeZone.server) {
startDate = new Date(
new TZDate(startDate, this.hassConfig.time_zone).getTime()
);
endDate = new Date(
new TZDate(endDate, this.hassConfig.time_zone).getTime()
);
}
if (
startDate.getHours() !== this._timeValue.from.hours ||
startDate.getMinutes() !== this._timeValue.from.minutes ||
endDate.getHours() !== this._timeValue.to.hours ||
endDate.getMinutes() !== this._timeValue.to.minutes
) {
this._timeValue.from.hours = startDate.getHours();
this._timeValue.from.minutes = startDate.getMinutes();
this._timeValue.to.hours = endDate.getHours();
this._timeValue.to.minutes = endDate.getMinutes();
}
fireEvent(this, "value-changed", {
value: {
startDate,
endDate,
},
});
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._focusDate = undefined;
}
private _handleChange(ev: CustomEvent) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
}
private _setDateRange(ev: CustomEvent<ActionDetail>) {
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],
endDate: dateRange[1],
},
});
fireEvent(this, "preset-selected", {
index: ev.detail.index,
});
}
private _handleChangeTime(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const time = ev.detail.value;
const type = (ev.target as HaBaseTimeInput).id;
if (time) {
if (!this._timeValue) {
this._timeValue = {
from: { hours: 0, minutes: 0 },
to: { hours: 23, minutes: 59 },
};
}
const [hours, minutes] = time.split(":").map(Number);
this._timeValue[type].hours = hours;
this._timeValue[type].minutes = minutes;
}
}
static styles = [
datePickerStyles,
dateRangePickerStyles,
css`
.picker {
display: flex;
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
}
.range {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: var(--ha-space-3);
}
.times {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.footer {
display: flex;
justify-content: flex-end;
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%;
}
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"date-range-picker": DateRangePicker;
}
interface HASSDomEvents {
"cancel-date-picker": undefined;
"preset-selected": { index: number };
}
}

View File

@@ -0,0 +1,406 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { shiftDateRange } from "../../common/datetime/calc_date";
import type { DateRange } from "../../common/datetime/calc_date_range";
import { calcDateRange } from "../../common/datetime/calc_date_range";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import "../ha-bottom-sheet";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-textarea";
import "./date-range-picker";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
const RANGE_KEYS: DateRange[] = ["today", "yesterday", "this_week"];
const EXTENDED_RANGE_KEYS: DateRange[] = [
"this_month",
"this_year",
"now-1h",
"now-12h",
"now-24h",
"now-7d",
"now-30d",
];
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@state()
@consume({ context: configContext, subscribe: true })
private hassConfig!: ContextType<typeof configContext>;
@property({ attribute: false }) public startDate!: Date;
@property({ attribute: false }) public endDate!: Date;
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
@state() private _ranges?: DateRangePickerRanges;
@property({ attribute: "time-picker", type: Boolean })
public timePicker = false;
@property({ type: Boolean, reflect: true })
public backdrop = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public minimal = false;
@property({ attribute: "extended-presets", type: Boolean })
public extendedPresets = false;
@property({ attribute: "popover-placement" })
public popoverPlacement:
| "bottom"
| "top"
| "left"
| "right"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@state() private _openedNarrow = false;
@state() private _popoverWidth = 0;
@query(".container") private _containerElement?: HTMLDivElement;
private _narrow = false;
private _unsubscribeTinyKeys?: () => void;
public connectedCallback() {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
const rangeKeys = this.extendedPresets
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
: RANGE_KEYS;
this._ranges = {};
rangeKeys.forEach((key) => {
this._ranges![
this.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this.locale, this.hassConfig, key);
});
}
public open(): void {
this._openPicker();
}
protected render(): TemplateResult {
return html`
<div class="container">
<div class="date-range-inputs">
${!this.minimal
? html`<ha-textarea
id="field"
mobile-multiline
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this.locale,
this.hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this.locale,
this.hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this.locale,
this.hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this.locale,
this.hassConfig
))}
.label=${this.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this.localize("ui.components.date-range-picker.end_date")}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`}
</div>
${this._pickerWrapperOpen || this._opened
? this._openedNarrow
? html`
<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
>
${this._renderPicker()}
</ha-bottom-sheet>
`
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
class=${this._opened ? "open" : ""}
without-arrow
distance="0"
.placement=${this.popoverPlacement}
for="field"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-hide=${this._handlePopoverHide}
@wa-after-hide=${this._hidePicker}
trap-focus
>
${this._renderPicker()}
</wa-popover>
`
: nothing}
</div>
`;
}
private _renderPicker() {
if (!this._opened) {
return nothing;
}
return html`
<date-range-picker
.ranges=${this.ranges === false ? false : this.ranges || this._ranges}
.startDate=${this.startDate}
.endDate=${this.endDate}
.timePicker=${this.timePicker}
@cancel-date-picker=${this._closePicker}
@value-changed=${this._closePicker}
>
</date-range-picker>
`;
}
private _hidePicker(ev: Event) {
ev.stopPropagation();
this._opened = false;
this._pickerWrapperOpen = false;
this._unsubscribeTinyKeys?.();
fireEvent(this, "picker-closed");
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this._unsubscribeTinyKeys?.();
}
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
if (!this._openedNarrow && this._pickerWrapperOpen) {
this._popoverWidth = this._containerElement?.offsetWidth || 250;
}
};
private _dialogOpened = () => {
this._opened = true;
this._setTextareaFocusStyle(true);
};
private _handlePopoverHide = () => {
this._opened = false;
};
private _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
private _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
private _shift(forward: boolean) {
if (!this.startDate) return;
const { start, end } = shiftDateRange(
this.startDate,
this.endDate,
forward,
this.locale,
this.hassConfig
);
this.startDate = start;
this.endDate = end;
fireEvent(this, "value-changed", {
value: {
startDate: this.startDate,
endDate: this.endDate,
},
});
}
private _closePicker() {
this._pickerWrapperOpen = false;
}
private _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
if (this._pickerWrapperOpen) {
ev?.stopImmediatePropagation();
return;
}
this._openedNarrow = this._narrow;
this._popoverWidth = this._containerElement?.offsetWidth || 250;
this._pickerWrapperOpen = true;
this._unsubscribeTinyKeys = tinykeys(this, {
Escape: this._handleEscClose,
});
}
private _handleKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._openPicker(ev);
}
}
private _handleEscClose = (ev: KeyboardEvent) => {
ev.stopPropagation();
};
private _setTextareaFocusStyle(focused: boolean) {
const textarea = this.renderRoot.querySelector("ha-textarea");
if (textarea) {
const foundation = (textarea as any).mdcFoundation;
if (foundation) {
if (focused) {
foundation.activateFocus();
} else {
foundation.deactivateFocus();
}
}
}
}
static styles = [
css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(
--safe-area-inset-right
) - var(--ha-space-8)
);
overflow: hidden;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-picker": HaDateRangePicker;
}
}

View File

@@ -8,16 +8,22 @@ import {
formatDateMonth,
formatDateShort,
formatDateYear,
} from "../common/datetime/format_date";
import { configContext, localeContext, localizeContext } from "../data/context";
import { DialogMixin } from "../dialogs/dialog-mixin";
import "./ha-button";
import type { DatePickerDialogParams } from "./ha-date-input";
import "./ha-dialog";
import "./ha-dialog-footer";
import "./ha-icon-button";
import "./ha-icon-button-next";
import "./ha-icon-button-prev";
formatISODateOnly,
} from "../../common/datetime/format_date";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import "../ha-button";
import type { DatePickerDialogParams } from "../ha-date-input";
import "../ha-dialog";
import "../ha-dialog-footer";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import { datePickerStyles } from "./styles";
type CalendarDate = HTMLElementTagNameMap["calendar-date"];
@@ -75,7 +81,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? {
year: this._pickerYear,
title: formatDateShort(date, this.locale, this.hassConfig),
dateString: this.params.value.substring(0, 10),
dateString: formatISODateOnly(date, this.locale, this.hassConfig),
}
: undefined;
}
@@ -160,7 +166,8 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this._value = {
year: formatDateYear(date, this.locale, this.hassConfig),
title: formatDateShort(date, this.locale, this.hassConfig),
dateString: value || date.toISOString().substring(0, 10),
dateString:
value || formatISODateOnly(date, this.locale, this.hassConfig),
};
if (setFocusDay) {
@@ -196,81 +203,14 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this.closeDialog();
}
static styles = css`
ha-dialog {
--dialog-content-padding: 0;
}
calendar-date {
width: 100%;
}
calendar-date::part(button) {
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
outline-offset: -2px;
outline-color: var(--ha-color-neutral-60);
}
calendar-month {
width: 100%;
margin: 0 auto;
min-height: calc(42px * 7);
}
calendar-month::part(heading) {
display: none;
}
calendar-month::part(day) {
color: var(--disabled-text-color);
font-size: var(--ha-font-size-m);
font-family: var(--ha-font-body);
}
calendar-month::part(button),
calendar-month::part(selected):focus-visible {
color: var(--primary-text-color);
height: 32px;
width: 32px;
margin: var(--ha-space-1);
border-radius: var(--ha-border-radius-circle);
}
calendar-month::part(button):focus-visible {
background-color: inherit;
outline: 1px solid var(--ha-color-neutral-60);
outline-offset: 2px;
}
calendar-month::part(button):hover {
background-color: var(--ha-color-fill-primary-quiet-hover);
}
calendar-month::part(today) {
color: var(--primary-color);
}
calendar-month::part(selected),
calendar-month::part(selected):hover {
color: var(--text-primary-color);
background-color: var(--primary-color);
height: 40px;
width: 40px;
margin: 0;
}
calendar-month::part(selected):focus-visible {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
.heading {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.month-year {
flex: 1;
text-align: center;
margin-left: 48px;
}
`;
static styles = [
datePickerStyles,
css`
ha-dialog {
--dialog-content-padding: 0;
}
`,
];
}
declare global {

View File

@@ -0,0 +1,144 @@
import { css } from "lit";
export const datePickerStyles = css`
calendar-range,
calendar-date {
width: 100%;
min-width: 300px;
}
calendar-date::part(button),
calendar-range::part(button) {
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
outline-offset: -2px;
outline-color: var(--ha-color-neutral-60);
}
calendar-month {
width: calc(40px * 7);
margin: 0 auto;
min-height: calc(42px * 7);
}
calendar-month::part(heading) {
display: none;
}
calendar-month::part(day) {
color: var(--disabled-text-color);
font-size: var(--ha-font-size-m);
font-family: var(--ha-font-body);
}
calendar-month::part(button) {
color: var(--primary-text-color);
height: 32px;
width: 32px;
margin: var(--ha-space-1);
border-radius: var(--ha-border-radius-circle);
}
calendar-month::part(button):focus-visible {
background-color: inherit;
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
calendar-month::part(button):hover {
background-color: var(--ha-color-fill-primary-quiet-hover);
}
calendar-month::part(today) {
color: var(--primary-color);
}
calendar-month::part(range-inner),
calendar-month::part(range-start),
calendar-month::part(range-end),
calendar-month::part(selected),
calendar-month::part(selected):hover {
color: var(--text-primary-color);
background-color: var(--primary-color);
height: 40px;
width: 40px;
margin: 0;
}
calendar-month::part(selected):focus-visible {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
calendar-month::part(outside) {
cursor: pointer;
}
.heading {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.month-year {
flex: 1;
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`
calendar-month::part(selected):focus-visible {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
calendar-month::part(range-inner),
calendar-month::part(range-start),
calendar-month::part(range-end),
calendar-month::part(range-inner):hover,
calendar-month::part(range-start):hover,
calendar-month::part(range-end):hover {
color: var(--text-primary-color);
background-color: var(--primary-color);
border-radius: var(--ha-border-radius-square);
display: block;
margin: 0;
}
calendar-month::part(range-start),
calendar-month::part(range-start):hover {
border-top-left-radius: var(--ha-border-radius-circle);
border-bottom-left-radius: var(--ha-border-radius-circle);
}
calendar-month::part(range-end),
calendar-month::part(range-end):hover {
border-top-right-radius: var(--ha-border-radius-circle);
border-bottom-right-radius: var(--ha-border-radius-circle);
}
calendar-month::part(range-start):hover,
calendar-month::part(range-end):hover,
calendar-month::part(range-inner):hover {
color: var(--primary-text-color);
}
`;

View File

@@ -1,359 +0,0 @@
import wrap from "@vue/web-component-wrapper";
import { customElement } from "lit/decorators";
import Vue from "vue";
import DateRangePicker from "vue2-daterange-picker";
// @ts-ignore
import dateRangePickerStyles from "vue2-daterange-picker/dist/vue2-daterange-picker.css";
import {
localizeMonths,
localizeWeekdays,
} from "../common/datetime/localize_date";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
// eslint-disable-next-line @typescript-eslint/naming-convention
const CustomDateRangePicker = Vue.extend({
mixins: [DateRangePicker],
methods: {
// Set the current date to the left picker instead of the right picker because the right is hidden
selectMonthDate() {
const dt: Date = this.end || new Date();
// @ts-ignore
this.changeLeftMonth({
year: dt.getFullYear(),
month: dt.getMonth() + 1,
});
},
// Fix the start/end date calculation when selecting a date range. The
// original code keeps track of the first clicked date (in_selection) but it
// never sets it to either the start or end date variables, so if the
// in_selection date is between the start and end date that were set by the
// hover the selection will enter a broken state that's counter-intuitive
// when hovering between weeks and leads to a random date when selecting a
// range across months. This bug doesn't seem to be present on v0.6.7 of the
// lib
hoverDate(value: Date) {
if (this.readonly) return;
if (this.in_selection) {
const pickA = this.in_selection as Date;
const pickB = value;
this.start = this.normalizeDatetime(
Math.min(pickA.valueOf(), pickB.valueOf()),
this.start
);
this.end = this.normalizeDatetime(
Math.max(pickA.valueOf(), pickB.valueOf()),
this.end
);
}
this.$emit("hover-date", value);
},
},
});
// eslint-disable-next-line @typescript-eslint/naming-convention
const Component = Vue.extend({
props: {
timePicker: {
type: Boolean,
default: true,
},
twentyfourHours: {
type: Boolean,
default: true,
},
openingDirection: {
type: String,
default: "right",
},
disabled: {
type: Boolean,
default: false,
},
ranges: {
type: Boolean,
default: true,
},
startDate: {
type: [String, Date],
default() {
return new Date();
},
},
endDate: {
type: [String, Date],
default() {
return new Date();
},
},
firstDay: {
type: Number,
default: 1,
},
autoApply: {
type: Boolean,
default: false,
},
language: {
type: String,
default: "en",
},
opensVertical: {
type: String,
default: undefined,
},
},
render(createElement) {
// @ts-expect-error
return createElement(CustomDateRangePicker, {
props: {
"time-picker": this.timePicker,
"auto-apply": this.autoApply,
opens: this.openingDirection,
"show-dropdowns": false,
"time-picker24-hour": this.twentyfourHours,
disabled: this.disabled,
ranges: this.ranges ? {} : false,
"locale-data": {
firstDay: this.firstDay,
daysOfWeek: localizeWeekdays(this.language, true),
monthNames: localizeMonths(this.language, false),
},
},
model: {
value: {
startDate: this.startDate,
endDate: this.endDate,
},
callback: (value) => {
fireEvent(this.$el as HTMLElement, "change", value);
},
expression: "dateRange",
},
on: {
toggle: (open: boolean) => {
fireEvent(this.$el as HTMLElement, "toggle", { open });
},
},
scopedSlots: {
input() {
return createElement("slot", {
domProps: { name: "input" },
});
},
header() {
return createElement("slot", {
domProps: { name: "header" },
});
},
ranges() {
return createElement("slot", {
domProps: { name: "ranges" },
});
},
footer() {
return createElement("slot", {
domProps: { name: "footer" },
});
},
},
});
},
});
// Assertion corrects HTMLElement type from package
// eslint-disable-next-line @typescript-eslint/naming-convention
const WrappedElement = wrap(
Vue,
Component
) as unknown as CustomElementConstructor;
@customElement("date-range-picker")
class DateRangePickerElement extends WrappedElement {
constructor() {
super();
const style = document.createElement("style");
style.innerHTML = `
${dateRangePickerStyles}
.calendars {
display: flex;
flex-wrap: nowrap !important;
}
.daterangepicker {
top: auto;
box-shadow: var(--ha-card-box-shadow, none);
background-color: var(--card-background-color);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
border-width: var(--ha-card-border-width, 1px);
border-style: solid;
border-color: var(
--ha-card-border-color,
var(--divider-color, #e0e0e0)
);
color: var(--primary-text-color);
min-width: initial !important;
max-height: var(--date-range-picker-max-height);
overflow-y: auto;
}
.daterangepicker:before {
display: none;
}
.daterangepicker:after {
border-bottom: 6px solid var(--card-background-color);
}
.daterangepicker .calendar-table {
background-color: var(--card-background-color);
border: none;
}
.daterangepicker .calendar-table td,
.daterangepicker .calendar-table th {
background-color: transparent;
color: var(--secondary-text-color);
border-radius: var(--ha-border-radius-square);
outline: none;
min-width: 32px;
height: 32px;
}
.daterangepicker td.off,
.daterangepicker td.off.end-date,
.daterangepicker td.off.in-range,
.daterangepicker td.off.start-date {
background-color: var(--secondary-background-color);
color: var(--disabled-text-color);
}
.daterangepicker td.in-range {
background-color: var(--light-primary-color);
color: var(--text-light-primary-color, var(--primary-text-color));
}
.daterangepicker td.active,
.daterangepicker td.active:hover {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
.daterangepicker td.start-date.end-date {
border-radius: var(--ha-border-radius-circle);
}
.daterangepicker td.start-date {
border-radius: var(--ha-border-radius-circle) var(--ha-border-radius-square) var(--ha-border-radius-square) var(--ha-border-radius-circle);
}
.daterangepicker td.end-date {
border-radius: var(--ha-border-radius-square) var(--ha-border-radius-circle) var(--ha-border-radius-circle) var(--ha-border-radius-square);
}
.reportrange-text {
background: none !important;
padding: 0 !important;
border: none !important;
}
.daterangepicker .calendar-table .next span,
.daterangepicker .calendar-table .prev span {
border: solid var(--primary-text-color);
border-width: 0 2px 2px 0;
}
.daterangepicker .ranges li {
outline: none;
}
.daterangepicker .ranges li:hover {
background-color: var(--secondary-background-color);
}
.daterangepicker .ranges li.active {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
.daterangepicker select.ampmselect,
.daterangepicker select.hourselect,
.daterangepicker select.minuteselect,
.daterangepicker select.secondselect {
background: var(--card-background-color);
border: 1px solid var(--divider-color);
color: var(--primary-color);
}
.daterangepicker .drp-buttons .btn {
border: 1px solid var(--primary-color);
background-color: transparent;
color: var(--primary-color);
border-radius: var(--ha-border-radius-sm);
padding: 8px;
cursor: pointer;
}
.calendars-container {
flex-direction: column;
align-items: center;
}
.drp-calendar.col.right .calendar-table {
display: none;
}
.daterangepicker.show-ranges .drp-calendar.left {
border-left: 0px;
}
.daterangepicker .drp-calendar.left {
padding: 8px;
width: unset;
max-width: unset;
min-width: 270px;
}
.daterangepicker.show-calendar .ranges {
margin-top: 0;
padding-top: 8px;
border-right: 1px solid var(--divider-color);
}
@media only screen and (max-width: 800px) {
.calendars {
flex-direction: column;
}
}
.calendar-table {
padding: 0 !important;
}
.calendar-time {
direction: ltr;
}
.daterangepicker.ltr {
direction: var(--direction);
text-align: var(--float-start);
}
.vue-daterange-picker{
min-width: unset !important;
display: block !important;
}
:host([opens-vertical="up"]) .daterangepicker {
bottom: 100%;
top: auto !important;
}
`;
if (mainWindow.document.dir === "rtl") {
style.innerHTML += `
.daterangepicker .calendar-table .next span {
transform: rotate(135deg);
-webkit-transform: rotate(135deg);
}
.daterangepicker .calendar-table .prev span {
transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
}
.daterangepicker td.start-date {
border-radius: var(--ha-border-radius-square) var(--ha-border-radius-circle) var(--ha-border-radius-circle) var(--ha-border-radius-square);
}
.daterangepicker td.end-date {
border-radius: var(--ha-border-radius-circle) var(--ha-border-radius-square) var(--ha-border-radius-square) var(--ha-border-radius-circle);
}
`;
}
const shadowRoot = this.shadowRoot!;
shadowRoot.appendChild(style);
// Stop click events from reaching the document, otherwise it will close the picker immediately.
shadowRoot.addEventListener("click", (ev) => ev.stopPropagation());
}
}
declare global {
interface HTMLElementTagNameMap {
"date-range-picker": DateRangePickerElement;
}
interface HASSDomEvents {
toggle: { open: boolean };
}
}

View File

@@ -1,12 +1,16 @@
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 type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import {
DEFAULT_ENTITY_NAME,
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";
@@ -14,12 +18,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>
@@ -70,10 +76,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) : [...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 _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) {
@@ -158,157 +445,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) : [];
});
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 _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this._items[this._editIndex];
@@ -359,58 +495,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;
@@ -418,13 +502,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;
@@ -442,30 +555,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;
}

View File

@@ -2,7 +2,7 @@ import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-multi-textfield";
import "./input/ha-input-multi";
@customElement("ha-aliases-editor")
class AliasesEditor extends LitElement {
@@ -12,28 +12,32 @@ class AliasesEditor extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public sortable = false;
protected render() {
if (!this.aliases) {
return nothing;
}
return html`
<ha-multi-textfield
.hass=${this.hass}
<ha-input-multi
.value=${this.aliases}
.disabled=${this.disabled}
.sortable=${this.sortable}
update-on-blur
.label=${this.hass!.localize("ui.dialogs.aliases.label")}
.removeLabel=${this.hass!.localize("ui.dialogs.aliases.remove")}
.addLabel=${this.hass!.localize("ui.dialogs.aliases.add")}
item-index
@value-changed=${this._aliasesChanged}
>
</ha-multi-textfield>
</ha-input-multi>
`;
}
private _aliasesChanged(value) {
fireEvent(this, "value-changed", { value });
private _aliasesChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value });
}
}

View File

@@ -9,7 +9,6 @@ import "./ha-expansion-panel";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreasDisplayValue {
hidden?: string[];

View File

@@ -15,7 +15,6 @@ import "./ha-floor-icon";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreasFloorsDisplayValue {
areas_display?: {

View File

@@ -1,9 +1,15 @@
import { mdiAlertCircle, mdiMicrophone, mdiSend } from "@mdi/js";
import {
mdiAlertCircle,
mdiChevronDown,
mdiChevronUp,
mdiCommentProcessingOutline,
mdiMicrophone,
mdiSend,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { haStyleScrollbar } from "../resources/styles";
import { supportsFeature } from "../common/entity/supports-feature";
import {
runAssistPipeline,
@@ -14,17 +20,28 @@ import {
} from "../data/assist_pipeline";
import { ConversationEntityFeature } from "../data/conversation";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
interface AssistMessage {
who: string;
text?: string | TemplateResult;
text: string | TemplateResult;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
string,
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: any;
}
>;
error?: boolean;
}
@@ -40,7 +57,7 @@ export class HaAssistChat extends LitElement {
@property({ attribute: false })
public startListening?: boolean;
@query("#message-input") private _messageInput!: HaTextField;
@query("#message-input") private _messageInput!: HaInput;
@query(".message:last-child")
private _lastChatMessage!: LitElement;
@@ -70,6 +87,8 @@ export class HaAssistChat extends LitElement {
{
who: "hass",
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
];
}
@@ -127,29 +146,114 @@ export class HaAssistChat extends LitElement {
`}
<div class="spacer"></div>
${this._conversation!.map(
(message) => html`
<ha-markdown
class="message ${classMap({
error: !!message.error,
[message.who]: true,
})}"
breaks
cache
.content=${message.text}
>
</ha-markdown>
(message, index) => html`
<div class="message-container ${classMap({ [message.who]: true })}">
${message.text ||
message.error ||
message.thinking ||
(message.tool_calls && Object.keys(message.tool_calls).length > 0)
? html`
<div
class="message ${classMap({
error: !!message.error,
[message.who]: true,
})}"
>
${message.thinking ||
(message.tool_calls &&
Object.keys(message.tool_calls).length > 0)
? html`
<div
class="thinking-wrapper ${classMap({
expanded: !!message.thinking_expanded,
})}"
>
<button
class="thinking-header"
.index=${index}
@click=${this._handleToggleThinking}
aria-expanded=${message.thinking_expanded
? "true"
: "false"}
>
<ha-svg-icon
.path=${mdiCommentProcessingOutline}
></ha-svg-icon>
<span class="thinking-label">
${this.hass.localize(
"ui.dialogs.voice_command.show_details"
)}
</span>
<ha-svg-icon
.path=${message.thinking_expanded
? mdiChevronUp
: mdiChevronDown}
></ha-svg-icon>
</button>
<div class="thinking-content">
${message.thinking
? html`<ha-markdown
.content=${message.thinking}
></ha-markdown>`
: nothing}
${message.tool_calls &&
Object.keys(message.tool_calls).length > 0
? html`
<div class="tool-calls">
${Object.values(message.tool_calls).map(
(toolCall) => html`
<div class="tool-call">
<div class="tool-name">
${toolCall.tool_name}
</div>
<div class="tool-data">
<pre>
${JSON.stringify(toolCall.tool_args, null, 2)}</pre
>
</div>
${toolCall.result
? html`
<div class="tool-data">
<pre>
${JSON.stringify(toolCall.result, null, 2)}</pre
>
</div>
`
: nothing}
</div>
`
)}
</div>
`
: nothing}
</div>
</div>
`
: nothing}
${message.text
? html`
<ha-markdown
breaks
cache
.content=${message.text}
></ha-markdown>
`
: nothing}
</div>
`
: nothing}
</div>
`
)}
</div>
<div class="input" slot="primaryAction">
<ha-textfield
<ha-input
id="message-input"
@keyup=${this._handleKeyUp}
@input=${this._handleInput}
.label=${this.hass.localize(`ui.dialogs.voice_command.input_label`)}
.iconTrailing=${true}
>
<div slot="trailingIcon">
<div slot="end">
${this._showSendButton || !supportsSTT
? html`
<ha-icon-button
@@ -194,7 +298,7 @@ export class HaAssistChat extends LitElement {
</div>
`}
</div>
</ha-textfield>
</ha-input>
</div>
`;
}
@@ -224,7 +328,7 @@ export class HaAssistChat extends LitElement {
}
private _handleKeyUp(ev: KeyboardEvent) {
const input = ev.target as HaTextField;
const input = ev.target as HaInput;
if (!this._processing && ev.key === "Enter" && input.value) {
this._processText(input.value);
input.value = "";
@@ -233,7 +337,7 @@ export class HaAssistChat extends LitElement {
}
private _handleInput(ev: InputEvent) {
const value = (ev.target as HaTextField).value;
const value = (ev.target as HaInput).value;
if (value && !this._showSendButton) {
this._showSendButton = true;
} else if (!value && this._showSendButton) {
@@ -268,6 +372,15 @@ export class HaAssistChat extends LitElement {
}
}
private _handleToggleThinking(ev: Event) {
const index = (ev.currentTarget as any).index;
this._conversation[index] = {
...this._conversation[index],
thinking_expanded: !this._conversation[index].thinking_expanded,
};
this.requestUpdate("_conversation");
}
private _addMessage(message: AssistMessage) {
this._conversation = [...this._conversation!, message];
}
@@ -296,7 +409,9 @@ export class HaAssistChat extends LitElement {
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
)}`,
)}`,
thinking: "",
tool_calls: {},
});
}
@@ -317,6 +432,8 @@ export class HaAssistChat extends LitElement {
const userMessage: AssistMessage = {
who: "user",
text: "…",
thinking: "",
tool_calls: {},
};
await this._audioRecorder.start();
@@ -448,7 +565,7 @@ export class HaAssistChat extends LitElement {
private async _processText(text: string) {
this._unloadAudio();
this._processing = true;
this._addMessage({ who: "user", text });
this._addMessage({ who: "user", text, thinking: "", tool_calls: {} });
const hassMessageProcesser = this._createAddHassMessageProcessor();
hassMessageProcesser.addMessage();
try {
@@ -487,17 +604,23 @@ export class HaAssistChat extends LitElement {
let currentDeltaRole = "";
const progressToNextMessage = () => {
if (progress.hassMessage.text === "…") {
if (
progress.hassMessage.text === "…" &&
!progress.hassMessage.thinking &&
(!progress.hassMessage.tool_calls ||
Object.keys(progress.hassMessage.tool_calls).length === 0)
) {
return;
}
progress.hassMessage.text = progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
);
if (progress.hassMessage.text?.endsWith("…")) {
progress.hassMessage.text = progress.hassMessage.text.slice(0, -1);
}
progress.hassMessage = {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
};
this._addMessage(progress.hassMessage);
@@ -513,16 +636,13 @@ export class HaAssistChat extends LitElement {
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
const tools: Record<
string,
ConversationChatLogAssistantDelta["tool_calls"][0]
> = {};
const progress = {
continueConversation: false,
hassMessage: {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
},
addMessage: () => {
@@ -540,29 +660,37 @@ export class HaAssistChat extends LitElement {
// new message
if (delta.role) {
progressToNextMessage();
currentDeltaRole = delta.role;
}
if (isAssistantDelta(delta)) {
if (delta.content) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
this.requestUpdate("_conversation");
if (progress.hassMessage.text.endsWith("…")) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
} else {
progress.hassMessage.text += delta.content + "…";
}
}
if (delta.thinking_content) {
progress.hassMessage.thinking += delta.thinking_content;
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
tools[toolCall.id] = toolCall;
progress.hassMessage.tool_calls[toolCall.id] = toolCall;
}
}
this.requestUpdate("_conversation");
} else if (isToolResult(delta)) {
if (tools[delta.tool_call_id]) {
delete tools[delta.tool_call_id];
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
progress.hassMessage.tool_calls[delta.tool_call_id].result =
delta.tool_result;
this.requestUpdate("_conversation");
}
}
} else if (event.type === "intent-end") {
@@ -599,9 +727,10 @@ export class HaAssistChat extends LitElement {
ha-alert {
margin-bottom: var(--ha-space-2);
}
ha-textfield {
display: block;
#message-input::part(wa-base) {
padding-right: var(--ha-space-1);
}
.messages {
flex: 1 1 400px;
display: block;
@@ -619,6 +748,17 @@ export class HaAssistChat extends LitElement {
.spacer {
flex: 1;
}
.message-container {
display: flex;
flex-direction: column;
margin: var(--ha-space-2) 0;
}
.message-container.user {
align-self: flex-end;
}
.message-container.hass {
align-self: flex-start;
}
.message {
font-size: var(--ha-font-size-l);
clear: both;
@@ -666,6 +806,89 @@ export class HaAssistChat extends LitElement {
background-color: var(--error-color);
color: var(--text-primary-color);
}
.thinking-wrapper {
margin: calc(var(--ha-space-2) * -1) calc(var(--ha-space-2) * -1) 0
calc(var(--ha-space-2) * -1);
overflow: hidden;
}
.thinking-wrapper:last-child {
margin-bottom: calc(var(--ha-space-2) * -1);
}
.thinking-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
width: 100%;
background: none;
border: none;
padding: var(--ha-space-2);
cursor: pointer;
text-align: left;
color: var(--secondary-text-color);
transition: color 0.2s;
}
.thinking-header:hover,
.thinking-header:focus {
outline: none;
color: var(--primary-text-color);
}
.thinking-label {
font-size: var(--ha-font-size-m);
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.thinking-header ha-svg-icon {
--mdc-icon-size: 16px;
}
.thinking-content {
max-height: 0;
overflow: hidden;
transition:
max-height 0.3s ease-in-out,
padding 0.3s;
padding: 0 var(--ha-space-2);
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.thinking-wrapper.expanded .thinking-content {
max-height: 500px;
padding: var(--ha-space-2);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.tool-calls {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
}
.tool-call {
padding: var(--ha-space-1) var(--ha-space-2);
border-left: 2px solid var(--divider-color);
margin-bottom: var(--ha-space-1);
}
.tool-name {
font-weight: bold;
display: flex;
align-items: center;
gap: var(--ha-space-1);
}
.tool-data {
font-family: var(--code-font-family, monospace);
font-size: 0.9em;
background: var(--markdown-code-background-color);
padding: var(--ha-space-1);
border-radius: var(--ha-border-radius-s);
margin-top: var(--ha-space-1);
overflow-x: auto;
}
.tool-data pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
ha-markdown {
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
--markdown-table-border-color: var(--divider-color);
@@ -721,20 +944,6 @@ export class HaAssistChat extends LitElement {
}
}
.listening-icon {
position: relative;
color: var(--secondary-text-color);
margin-right: -24px;
margin-inline-end: -24px;
margin-inline-start: initial;
direction: var(--direction);
transform: scaleX(var(--scale-direction));
}
.listening-icon[active] {
color: var(--primary-color);
}
.unsupported {
color: var(--error-color);
position: absolute;

View File

@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-select";
@@ -133,6 +134,9 @@ export class HaBaseTimeInput extends LitElement {
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
@property({ attribute: "placeholder-labels", type: Boolean })
public placeholderLabels = false;
@queryAll("ha-input") private _inputs?: HaInput[];
static shadowRootOptions = {
@@ -158,7 +162,8 @@ export class HaBaseTimeInput extends LitElement {
type="number"
inputmode="numeric"
.value=${this.days.toFixed()}
.label=${this.dayLabel}
.label=${!this.placeholderLabels ? this.dayLabel : ""}
.placeholder=${this.placeholderLabels ? this.dayLabel : ""}
name="days"
@change=${this._valueChanged}
@focusin=${this._onFocus}
@@ -178,7 +183,8 @@ export class HaBaseTimeInput extends LitElement {
type="number"
inputmode="numeric"
.value=${this.hours.toFixed()}
.label=${this.hourLabel}
.label=${!this.placeholderLabels ? this.hourLabel : ""}
.placeholder=${this.placeholderLabels ? this.hourLabel : ""}
name="hours"
@change=${this._valueChanged}
@focusin=${this._onFocus}
@@ -197,7 +203,8 @@ export class HaBaseTimeInput extends LitElement {
type="number"
inputmode="numeric"
.value=${this._formatValue(this.minutes)}
.label=${this.minLabel}
.label=${!this.placeholderLabels ? this.minLabel : ""}
.placeholder=${this.placeholderLabels ? this.minLabel : ""}
@change=${this._valueChanged}
@focusin=${this._onFocus}
name="minutes"
@@ -220,7 +227,8 @@ export class HaBaseTimeInput extends LitElement {
inputmode="decimal"
step="any"
.value=${this._formatValue(this.seconds)}
.label=${this.secLabel}
.label=${!this.placeholderLabels ? this.secLabel : ""}
.placeholder=${this.placeholderLabels ? this.secLabel : ""}
@change=${this._valueChanged}
@focusin=${this._onFocus}
name="seconds"
@@ -241,7 +249,8 @@ export class HaBaseTimeInput extends LitElement {
id="millisec"
type="number"
.value=${this._formatValue(this.milliseconds, 3)}
.label=${this.millisecLabel}
.label=${!this.placeholderLabels ? this.millisecLabel : ""}
.placeholder=${this.placeholderLabels ? this.millisecLabel : ""}
@change=${this._valueChanged}
@focusin=${this._onFocus}
name="milliseconds"
@@ -263,6 +272,8 @@ export class HaBaseTimeInput extends LitElement {
.disabled=${this.disabled}
.name=${"amPm"}
@selected=${this._valueChanged}
@wa-after-hide=${stopPropagation}
@wa-hide=${stopPropagation}
.options=${["AM", "PM"]}
>
</ha-select>`}

View File

@@ -8,7 +8,6 @@ import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer"
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -90,21 +89,22 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass.auth.external)) {
const element = this.renderRoot.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-bottom-sheet-autofocus";
}
this.hass.auth.external?.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
// disabled till iOS app fix the "focus_element" implementation
// if (this.hass && isIosApp(this.hass.auth.external)) {
// const element = this.renderRoot.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-bottom-sheet-autofocus";
// }
// this.hass.auth.external?.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
// },
// });
// }
// return;
// }
(
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
)?.focus();

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;

View File

@@ -8,10 +8,14 @@ import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import { localizeContext } from "../data/context";
import type { UiColorExtraOption } from "../data/selector";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
@customElement("ha-color-picker")
export class HaColorPicker extends LitElement {
@@ -30,8 +34,24 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean, attribute: "include_none" })
public includeNone = false;
@property({ attribute: false })
public extraOptions?: UiColorExtraOption[];
@property({ type: Boolean }) public disabled = false;
private _extraOptionsColorMap = memoizeOne(
(extraOptions?: UiColorExtraOption[]) => {
if (!extraOptions) return undefined;
const map = new Map<string, string>();
for (const option of extraOptions) {
if (option.display_color) {
map.set(option.value, option.display_color);
}
}
return map.size > 0 ? map : undefined;
}
);
@property({ type: Boolean }) public required = false;
@state()
@@ -71,6 +91,7 @@ export class HaColorPicker extends LitElement {
const colors = this._getColors(
this.includeNone,
this.includeState,
this.extraOptions,
this.defaultColor,
this.value
);
@@ -93,6 +114,7 @@ export class HaColorPicker extends LitElement {
this._getColors(
this.includeNone,
this.includeState,
this.extraOptions,
this.defaultColor,
this.value
);
@@ -101,6 +123,7 @@ export class HaColorPicker extends LitElement {
(
includeNone: boolean,
includeState: boolean,
extraOptions: UiColorExtraOption[] | undefined,
defaultColor: string | undefined,
currentValue: string | undefined
): PickerComboBoxItem[] => {
@@ -132,6 +155,19 @@ export class HaColorPicker extends LitElement {
});
}
if (extraOptions) {
extraOptions.forEach((option) => {
items.push({
id: option.value,
primary: addDefaultSuffix(
option.label,
defaultColor === option.value
),
...(option.icon ? { icon: option.icon } : {}),
});
});
}
Array.from(THEME_COLORS).forEach((color) => {
const themeLabel =
this.localize?.(
@@ -143,14 +179,11 @@ export class HaColorPicker extends LitElement {
});
});
const isSpecial =
currentValue === "none" ||
currentValue === "state" ||
THEME_COLORS.has(currentValue || "");
const knownIds = new Set(items.map((item) => item.id));
const hasValue = currentValue && currentValue.length > 0;
if (hasValue && !isSpecial) {
if (hasValue && !knownIds.has(currentValue!)) {
items.push({
id: currentValue!,
primary: currentValue!,
@@ -161,21 +194,27 @@ export class HaColorPicker extends LitElement {
}
);
private _renderItemIcon(item: PickerComboBoxItem) {
if (item.icon_path) {
return html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`;
}
if (item.icon) {
return html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`;
}
const color =
this._extraOptionsColorMap(this.extraOptions)?.get(item.id) ?? item.id;
return html`<span slot="start">${this._renderColorCircle(color)}</span>`;
}
private _rowRenderer: (
item: PickerComboBoxItem,
index?: number
) => ReturnType<typeof html> = (item) => html`
<ha-combo-box-item type="button" compact>
${item.id === "none"
? html`<ha-svg-icon
slot="start"
.path=${mdiInvertColorsOff}
></ha-svg-icon>`
: item.id === "state"
? html`<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>`
: html`<span slot="start">
${this._renderColorCircle(item.id)}
</span>`}
${this._renderItemIcon(item)}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
@@ -201,13 +240,23 @@ export class HaColorPicker extends LitElement {
`;
}
const extraOption = this.extraOptions?.find((o) => o.value === value);
const label =
extraOption?.label ||
this.localize?.(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) ||
value;
const color =
this._extraOptionsColorMap(this.extraOptions)?.get(value) ?? value;
const startSlot = extraOption?.icon
? html`<ha-icon slot="start" .icon=${extraOption.icon}></ha-icon>`
: html`<span slot="start">${this._renderColorCircle(color)}</span>`;
return html`
<span slot="start">${this._renderColorCircle(value)}</span>
<span slot="headline">
${this.localize?.(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) || value}
</span>
${startSlot}
<span slot="headline">${label}</span>
`;
};

View File

@@ -43,30 +43,6 @@ export class HaConversationAgentPicker extends LitElement {
return nothing;
}
let value = this.value;
if (!value && this.required) {
// Select Home Assistant conversation agent if it supports the language
for (const agent of this._agents) {
if (
agent.id === "conversation.home_assistant" &&
agent.supported_languages.includes(this.language!)
) {
value = agent.id;
break;
}
}
if (!value) {
// Select the first agent that supports the language
for (const agent of this._agents) {
if (
agent.supported_languages === "*" &&
agent.supported_languages.includes(this.language!)
) {
value = agent.id;
break;
}
}
}
}
if (!value) {
value = NONE;
}
@@ -170,6 +146,39 @@ export class HaConversationAgentPicker extends LitElement {
this._agents = agents;
if (!this.value && this.required) {
let defaultValue: string | undefined;
// Select Home Assistant conversation agent if it supports the language
for (const agent of this._agents) {
if (
agent.id === "conversation.home_assistant" &&
(!this.language ||
agent.supported_languages === "*" ||
agent.supported_languages.includes(this.language))
) {
defaultValue = agent.id;
break;
}
}
if (!defaultValue) {
// Select the first agent that supports the language
for (const agent of this._agents) {
if (
agent.supported_languages === "*" ||
!this.language ||
agent.supported_languages.includes(this.language)
) {
defaultValue = agent.id;
break;
}
}
}
if (defaultValue) {
this.value = defaultValue;
fireEvent(this, "value-changed", { value: this.value });
}
}
if (!this.value) {
return;
}

View File

@@ -1,110 +0,0 @@
import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { copyToClipboard } from "../common/util/copy-clipboard";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-copy-textfield")
export class HaCopyTextfield extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "value" }) public value!: string;
@property({ attribute: "masked-value" }) public maskedValue?: string;
@property({ attribute: "label" }) public label?: string;
@state() private _showMasked = true;
public render() {
return html`
<div class="container">
<div class="textfield-container">
<ha-textfield
.value=${this._showMasked && this.maskedValue
? this.maskedValue
: this.value}
readonly
.suffix=${this.maskedValue
? html`<div style="width: 24px"></div>`
: nothing}
@click=${this._focusInput}
></ha-textfield>
${this.maskedValue
? html`<ha-icon-button
class="toggle-unmasked"
.label=${this.hass.localize(
`ui.common.${this._showMasked ? "show" : "hide"}`
)}
@click=${this._toggleMasked}
.path=${this._showMasked ? mdiEye : mdiEyeOff}
></ha-icon-button>`
: nothing}
</div>
<ha-button @click=${this._copy} appearance="plain" size="small">
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
${this.label || this.hass.localize("ui.common.copy")}
</ha-button>
</div>
`;
}
private _focusInput(ev) {
const inputElement = ev.currentTarget as HaTextField;
inputElement.select();
}
private _toggleMasked(): void {
this._showMasked = !this._showMasked;
}
private async _copy(): Promise<void> {
await copyToClipboard(this.value);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static styles = css`
.container {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: 8px;
}
.textfield-container {
position: relative;
flex: 1;
}
.textfield-container ha-textfield {
display: block;
}
.toggle-unmasked {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--ha-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-copy-textfield": HaCopyTextfield;
}
}

View File

@@ -8,10 +8,11 @@ import { fireEvent } from "../common/dom/fire_event";
import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
const loadDatePickerDialog = () => import("./ha-dialog-date-picker");
const loadDatePickerDialog = () =>
import("./date-picker/ha-dialog-date-picker");
export interface DatePickerDialogParams {
value?: string;
@@ -53,19 +54,17 @@ export class HaDateInput extends LitElement {
@property({ attribute: "can-clear", type: Boolean }) public canClear = false;
@query("ha-textfield", true) private _input?: HaTextField;
@query("ha-input", true) private _input?: HaInput;
public reportValidity(): boolean {
return this._input?.reportValidity() ?? true;
}
render() {
return html`<ha-textfield
.label=${this.label}
.helper=${this.helper}
return html`<ha-input
.label=${this.label ?? ""}
.hint=${this.helper ?? ""}
.disabled=${this.disabled}
iconTrailing
helperPersistent
readonly
@click=${this._openDialog}
@keydown=${this._keyDown}
@@ -81,8 +80,8 @@ export class HaDateInput extends LitElement {
: ""}
.required=${this.required}
>
<ha-svg-icon slot="trailingIcon" .path=${mdiCalendar}></ha-svg-icon>
</ha-textfield>`;
<ha-svg-icon slot="end" .path=${mdiCalendar}></ha-svg-icon>
</ha-input>`;
}
private _openDialog() {
@@ -127,9 +126,6 @@ export class HaDateInput extends LitElement {
ha-svg-icon {
color: var(--secondary-text-color);
}
ha-textfield {
display: block;
}
`;
}
declare global {

View File

@@ -1,422 +0,0 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import { mdiCalendar } from "@mdi/js";
import { isThisYear } from "date-fns";
import { TZDate } from "@date-fns/tz";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { shiftDateRange } from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../common/datetime/format_date_time";
import { useAmPm } from "../common/datetime/use_am_pm";
import { fireEvent } from "../common/dom/fire_event";
import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types";
import "./date-range-picker";
import "./ha-button";
import "./ha-icon-button";
import "./ha-icon-button-next";
import "./ha-icon-button-prev";
import "./ha-list";
import "./ha-list-item";
import "./ha-textarea";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
declare global {
interface HASSDomEvents {
"preset-selected": { index: number };
}
}
const RANGE_KEYS: DateRange[] = ["today", "yesterday", "this_week"];
const EXTENDED_RANGE_KEYS: DateRange[] = [
"this_month",
"this_year",
"now-1h",
"now-12h",
"now-24h",
"now-7d",
"now-30d",
];
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public startDate!: Date;
@property({ attribute: false }) public endDate!: Date;
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
@state() private _ranges?: DateRangePickerRanges;
@property({ attribute: "auto-apply", type: Boolean })
public autoApply = false;
@property({ attribute: "time-picker", type: Boolean })
public timePicker = false;
public open(): void {
this._openPicker();
}
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public minimal = false;
@state() private _hour24format = false;
@property({ attribute: "extended-presets", type: Boolean })
public extendedPresets = false;
@property({ attribute: "vertical-opening-direction" })
public verticalOpeningDirection?: "up" | "down";
@property({ attribute: false }) public openingDirection?:
| "right"
| "left"
| "center"
| "inline";
@state() private _calcedOpeningDirection?:
| "right"
| "left"
| "center"
| "inline";
@state() private _calcedVerticalOpeningDirection?: "up" | "down";
protected willUpdate(changedProps: PropertyValues) {
if (
(!this.hasUpdated && this.ranges === undefined) ||
(changedProps.has("hass") &&
this.hass?.localize !== changedProps.get("hass")?.localize)
) {
const rangeKeys = this.extendedPresets
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
: RANGE_KEYS;
this._ranges = {};
rangeKeys.forEach((key) => {
this._ranges![
this.hass.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this.hass, key);
});
}
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass.locale) {
this._hour24format = !useAmPm(this.hass.locale);
}
}
}
protected render(): TemplateResult {
return html`
<date-range-picker
?disabled=${this.disabled}
?auto-apply=${this.autoApply}
time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format}
start-date=${this._formatDate(this.startDate)}
end-date=${this._formatDate(this.endDate)}
?ranges=${this.ranges !== false}
opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection
)}
opens-vertical=${ifDefined(
this.verticalOpeningDirection || this._calcedVerticalOpeningDirection
)}
first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language}
@change=${this._handleChange}
>
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
${!this.minimal
? html`<ha-textarea
mobile-multiline
.value=${(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this.hass.locale,
this.hass.config
)
: formatShortDateTimeWithYear(
this.startDate,
this.hass.locale,
this.hass.config
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this.hass.locale,
this.hass.config
)
: formatShortDateTimeWithYear(
this.endDate,
this.hass.locale,
this.hass.config
))}
.label=${this.hass.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this.hass.localize(
"ui.components.date-range-picker.end_date"
)}
.disabled=${this.disabled}
@click=${this._handleInputClick}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this.hass.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this.hass.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
.label=${this.hass.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`}
</div>
${this.ranges !== false && (this.ranges || this._ranges)
? html`<div slot="ranges" class="date-range-ranges">
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges || this._ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
)}
</ha-list>
</div>`
: nothing}
<div slot="footer" class="date-range-footer">
<ha-button appearance="plain" @click=${this._cancelDateRange}
>${this.hass.localize("ui.common.cancel")}</ha-button
>
<ha-button @click=${this._applyDateRange}
>${this.hass.localize(
"ui.components.date-range-picker.select"
)}</ha-button
>
</div>
</date-range-picker>
`;
}
private _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
private _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
private _shift(forward: boolean) {
if (!this.startDate) return;
const { start, end } = shiftDateRange(
this.startDate,
this.endDate,
forward,
this.hass.locale,
this.hass.config
);
this.startDate = start;
this.endDate = end;
const dateRange = [start, end];
const dateRangePicker = this._dateRangePicker;
dateRangePicker.clickRange(dateRange);
dateRangePicker.clickedApply();
}
private _setDateRange(ev: CustomEvent<ActionDetail>) {
const dateRange = Object.values(this.ranges || this._ranges!)[
ev.detail.index
];
fireEvent(this, "preset-selected", {
index: ev.detail.index,
});
const dateRangePicker = this._dateRangePicker;
dateRangePicker.clickRange(dateRange);
dateRangePicker.clickedApply();
}
private _cancelDateRange() {
this._dateRangePicker.clickCancel();
}
private _applyDateRange() {
let start = new Date(this._dateRangePicker.start);
let end = new Date(this._dateRangePicker.end);
if (this.timePicker) {
start.setSeconds(0);
start.setMilliseconds(0);
end.setSeconds(0);
end.setMilliseconds(0);
if (
end.getHours() === 0 &&
end.getMinutes() === 0 &&
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
) {
end.setDate(end.getDate() + 1);
}
}
if (this.hass.locale.time_zone === TimeZone.server) {
start = new Date(new TZDate(start, this.hass.config.time_zone).getTime());
end = new Date(new TZDate(end, this.hass.config.time_zone).getTime());
}
if (
start.getTime() !== this._dateRangePicker.start.getTime() ||
end.getTime() !== this._dateRangePicker.end.getTime()
) {
this._dateRangePicker.clickRange([start, end]);
}
this._dateRangePicker.clickedApply();
}
private _formatDate(date: Date): string {
if (this.hass.locale.time_zone === TimeZone.server) {
return new TZDate(date, this.hass.config.time_zone).toISOString();
}
return date.toISOString();
}
private get _dateRangePicker() {
const dateRangePicker = this.shadowRoot!.querySelector(
"date-range-picker"
) as any;
return dateRangePicker.vueComponent.$children[0];
}
private _openPicker() {
if (!this._dateRangePicker.open) {
const datePicker = this.shadowRoot!.querySelector(
"date-range-picker div.date-range-inputs"
) as any;
datePicker?.click();
}
}
private _handleInputClick() {
// close the date picker, so it will open again on the click event
if (this._dateRangePicker.open) {
this._dateRangePicker.open = false;
}
}
private _handleClick() {
// calculate opening direction if not set
if (!this._dateRangePicker.open) {
if (!this.openingDirection) {
const datePickerPosition = this.getBoundingClientRect().x;
let opens: "right" | "left" | "center" | "inline";
if (datePickerPosition > (2 * window.innerWidth) / 3) {
opens = "left";
} else if (datePickerPosition < window.innerWidth / 3) {
opens = "right";
} else {
opens = "center";
}
this._calcedOpeningDirection = opens;
}
if (!this.verticalOpeningDirection) {
const rect = this.getBoundingClientRect();
this._calcedVerticalOpeningDirection =
rect.top > window.innerHeight / 2 ? "up" : "down";
}
}
}
private _handleChange(ev: CustomEvent) {
ev.stopPropagation();
const startDate = ev.detail.startDate;
const endDate = ev.detail.endDate;
fireEvent(this, "value-changed", {
value: { startDate, endDate },
});
}
static styles = css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
}
.date-range-footer {
display: flex;
justify-content: flex-end;
padding: 8px;
border-top: 1px solid var(--divider-color);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
@media only screen and (max-width: 800px) {
.date-range-ranges {
border-right: none;
border-bottom: 1px solid var(--divider-color);
}
}
@media only screen and (max-height: 940px) and (max-width: 800px) {
.date-range-ranges {
overflow: auto;
max-height: calc(70vh - 330px);
min-height: 160px;
}
:host([header-position]) .date-range-ranges {
max-height: calc(90vh - 430px);
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-picker": HaDateRangePicker;
}
}

View File

@@ -14,10 +14,9 @@ import { ifDefined } from "lit/directives/if-defined";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { withViewTransition } from "../common/util/view-transition";
import { authContext, localizeContext } from "../data/context";
import { localizeContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import { isIosApp } from "../util/is_ios";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -127,9 +126,10 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: authContext, subscribe: true })
private auth?: ContextType<typeof authContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
@state()
private _bodyScrolled = false;
@@ -221,21 +221,22 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
if (this.auth?.external && isIosApp(this.auth.external)) {
const element = this.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-dialog-autofocus";
}
this.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
// disabled till iOS app fix the "focus_element" implementation
// if (this.auth?.external && isIosApp(this.auth.external)) {
// const element = this.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-dialog-autofocus";
// }
// this.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
// },
// });
// }
// return;
// }
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
});
};

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;
}

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;
}

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;
}

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;
}

View File

@@ -10,8 +10,8 @@ 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 +21,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 +80,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"
@@ -163,8 +164,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>>>) {
@@ -261,7 +263,7 @@ export class HaFilterLabels extends LitElement {
right: 0;
left: 0;
}
search-input-outlined {
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}

View File

@@ -1,10 +1,10 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-textfield";
import type { HaTextField } from "../ha-textfield";
import "../input/ha-input";
import type { HaInput } from "../input/ha-input";
import type {
HaFormElement,
HaFormFloatData,
@@ -25,7 +25,7 @@ export class HaFormFloat extends LitElement implements HaFormElement {
@property({ type: Boolean }) public disabled = false;
@query("ha-textfield", true) private _input?: HaTextField;
@query("ha-input", true) private _input?: HaInput;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
@@ -38,23 +38,25 @@ export class HaFormFloat extends LitElement implements HaFormElement {
protected render(): TemplateResult {
return html`
<ha-textfield
<ha-input
type="number"
inputMode="decimal"
step="any"
.label=${this.label}
.helper=${this.helper}
helperPersistent
.hint=${this.helper}
.value=${this.data !== undefined ? this.data : ""}
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.suffix=${this.schema.description?.suffix}
.validationMessage=${this.schema.required
? this.localize?.("ui.common.error_required")
: undefined}
@input=${this._valueChanged}
></ha-textfield>
@input=${this._handleInput}
>
${this.schema.description?.suffix
? html`<span slot="end">${this.schema.description?.suffix}</span>`
: nothing}
</ha-input>
`;
}
@@ -64,9 +66,9 @@ export class HaFormFloat extends LitElement implements HaFormElement {
}
}
private _valueChanged(ev: Event) {
const source = ev.target as HaTextField;
const rawValue = source.value.replace(",", ".");
private _handleInput(ev: InputEvent) {
const source = ev.target as HaInput;
const rawValue = (source.value ?? "").replace(",", ".");
let value: number | undefined;
@@ -74,6 +76,11 @@ export class HaFormFloat extends LitElement implements HaFormElement {
return;
}
// Allow user to keep typing decimal places (e.g., 5.0, 5.00, 5.10)
if (rawValue.includes(".") && rawValue.endsWith("0")) {
return;
}
// Allow user to start typing a negative value
if (rawValue === "-") {
return;
@@ -105,9 +112,6 @@ export class HaFormFloat extends LitElement implements HaFormElement {
:host([own-margin]) {
margin-bottom: 5px;
}
ha-textfield {
display: block;
}
`;
}

View File

@@ -1,5 +1,5 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
@@ -7,8 +7,8 @@ import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-input-helper-text";
import "../ha-slider";
import "../ha-textfield";
import type { HaTextField } from "../ha-textfield";
import "../input/ha-input";
import type { HaInput } from "../input/ha-input";
import type {
HaFormElement,
HaFormIntegerData,
@@ -29,8 +29,8 @@ export class HaFormInteger extends LitElement implements HaFormElement {
@property({ type: Boolean }) public disabled = false;
@query("ha-textfield, ha-slider", true) private _input?:
| HaTextField
@query("ha-input, ha-slider", true) private _input?:
| HaInput
| HTMLInputElement;
private _lastValue?: HaFormIntegerData;
@@ -89,28 +89,30 @@ export class HaFormInteger extends LitElement implements HaFormElement {
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: ""}
: nothing}
</div>
`;
}
return html`
<ha-textfield
<ha-input
type="number"
inputMode="numeric"
.label=${this.label}
.helper=${this.helper}
helperPersistent
.value=${this.data !== undefined ? this.data : ""}
.hint=${this.helper}
.value=${this.data !== undefined ? this.data.toString() : ""}
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.suffix=${this.schema.description?.suffix}
.validationMessage=${this.schema.required
? this.localize?.("ui.common.error_required")
: undefined}
@input=${this._valueChanged}
></ha-textfield>
>
${this.schema.description?.suffix
? html`<span slot="end">${this.schema.description.suffix}</span>`
: nothing}
</ha-input>
`;
}
@@ -167,8 +169,8 @@ export class HaFormInteger extends LitElement implements HaFormElement {
});
}
private _valueChanged(ev: Event) {
const source = ev.target as HaTextField | HTMLInputElement;
private _valueChanged(ev: InputEvent) {
const source = ev.target as HaInput | HTMLInputElement;
const rawValue = source.value;
let value: number | undefined;
@@ -201,9 +203,6 @@ export class HaFormInteger extends LitElement implements HaFormElement {
ha-slider {
flex: 1;
}
ha-textfield {
display: block;
}
`;
}

View File

@@ -199,11 +199,6 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
padding-inline-start: initial;
direction: var(--direction);
}
ha-textfield {
display: block;
width: 100%;
pointer-events: none;
}
ha-icon-button {
color: var(--input-dropdown-icon-color);
position: absolute;

View File

@@ -76,10 +76,12 @@ export class HaGauge extends LitElement {
const arcRadius = 40;
const arcLength = Math.PI * arcRadius;
const valueAngle = getAngle(this.value, this.min, this.max);
const strokeOffset = arcLength * (1 - valueAngle / 180);
const strokeOffset = this._updated
? arcLength * (1 - valueAngle / 180)
: arcLength;
return svg`
<svg viewBox="-50 -50 100 60" class="gauge">
<svg viewBox="-50 -50 100 55" class="gauge">
<path
class="levels-base"
d="M -40 0 A 40 40 0 0 1 40 0"
@@ -181,7 +183,7 @@ export class HaGauge extends LitElement {
<text
class="value-text"
x="0"
y="-10"
y="-5"
dominant-baseline="middle"
text-anchor="middle"
>
@@ -222,22 +224,22 @@ export class HaGauge extends LitElement {
.levels-base {
fill: none;
stroke: var(--primary-background-color);
stroke-width: 10;
stroke-width: 8;
stroke-linecap: round;
}
.level {
fill: none;
stroke-width: 10;
stroke-width: 8;
stroke-linecap: butt;
}
.value {
fill: none;
stroke-width: 10;
stroke-width: 8;
stroke: var(--gauge-color);
stroke-linecap: round;
transition: all 1s ease 0s;
transition: stroke-dashoffset 1s ease 0s;
}
.needle {
@@ -249,6 +251,7 @@ export class HaGauge extends LitElement {
}
.value-text {
font-size: var(--ha-font-size-l);
fill: var(--primary-text-color);
direction: ltr;
}

View File

@@ -1,6 +1,5 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
import {
css,
@@ -14,10 +13,8 @@ import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { authContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
@@ -113,9 +110,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@state()
@consume({ context: authContext, subscribe: true })
private auth?: ContextType<typeof authContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
@state() private _opened = false;
@@ -316,15 +314,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._comboBox?.setFieldValue(this._initialFieldValue);
this._initialFieldValue = undefined;
}
if (this.auth?.external && isIosApp(this.auth.external)) {
this.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: "combo-box",
},
});
return;
}
// disabled till iOS app fix the "focus_element" implementation
// if (this.auth?.external && isIosApp(this.auth.external)) {
// this.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: "combo-box",
// },
// });
// return;
// }
this._comboBox?.focus();
});

View File

@@ -32,12 +32,6 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public step = 1;
@property({ type: Boolean, attribute: "rows-disabled" })
public rowsDisabled?: boolean;
@property({ type: Boolean, attribute: "columns-disabled" })
public columnsDisabled?: boolean;
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
protected willUpdate(changedProperties) {
@@ -47,16 +41,15 @@ export class HaGridSizeEditor extends LitElement {
}
protected render() {
const disabledColumns =
this.columnsDisabled ||
(this.columnMin !== undefined && this.columnMin === this.columnMax);
const disabledRows =
this.rowsDisabled ||
(this.rowMin !== undefined && this.rowMin === this.rowMax);
const autoHeight = this._localValue?.rows === "auto";
const fullWidth = this._localValue?.columns === "full";
const disabledColumns =
fullWidth ||
(this.columnMin !== undefined && this.columnMin === this.columnMax);
const disabledRows =
autoHeight || (this.rowMin !== undefined && this.rowMin === this.rowMax);
const rowMin = this.rowMin ?? 1;
const rowMax = this.rowMax ?? this.rows;
const columnMin = Math.ceil((this.columnMin ?? 1) / this.step) * this.step;

View File

@@ -1,168 +0,0 @@
import { mdiDeleteOutline, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-button";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-multi-textfield")
class HaMultiTextField extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property({ attribute: false }) public helper?: string;
@property({ attribute: false }) public inputType?: string;
@property({ attribute: false }) public inputSuffix?: string;
@property({ attribute: false }) public inputPrefix?: string;
@property({ attribute: false }) public autocomplete?: string;
@property({ attribute: false }) public addLabel?: string;
@property({ attribute: false }) public removeLabel?: string;
@property({ attribute: "item-index", type: Boolean })
public itemIndex = false;
@property({ type: Number }) public max?: number;
protected render() {
return html`
${this._items.map((item, index) => {
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
return html`
<div class="layout horizontal center-center row">
<ha-textfield
.suffix=${this.inputSuffix}
.prefix=${this.inputPrefix}
.type=${this.inputType}
.autocomplete=${this.autocomplete}
.disabled=${this.disabled}
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
.label=${`${this.label ? `${this.label}${indexSuffix}` : ""}`}
.value=${item}
?data-last=${index === this._items.length - 1}
@input=${this._editItem}
@keydown=${this._keyDown}
></ha-textfield>
<ha-icon-button
.disabled=${this.disabled}
.index=${index}
slot="navigationIcon"
.label=${this.removeLabel ??
this.hass?.localize("ui.common.remove") ??
"Remove"}
@click=${this._removeItem}
.path=${mdiDeleteOutline}
></ha-icon-button>
</div>
`;
})}
<div class="layout horizontal">
<ha-button
size="small"
appearance="filled"
@click=${this._addItem}
.disabled=${this.disabled ||
(this.max != null && this._items.length >= this.max)}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.addLabel ??
(this.label
? this.hass?.localize("ui.components.multi-textfield.add_item", {
item: this.label,
})
: this.hass?.localize("ui.common.add")) ??
"Add"}
</ha-button>
</div>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`;
}
private get _items() {
return this.value ?? [];
}
private async _addItem() {
if (this.max != null && this._items.length >= this.max) {
return;
}
const items = [...this._items, ""];
this._fireChanged(items);
await this.updateComplete;
const field = this.shadowRoot?.querySelector(`ha-textfield[data-last]`) as
| HaTextField
| undefined;
field?.focus();
}
private async _editItem(ev: Event) {
const index = (ev.target as any).index;
const items = [...this._items];
items[index] = (ev.target as any).value;
this._fireChanged(items);
}
private async _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
ev.stopPropagation();
this._addItem();
}
}
private async _removeItem(ev: Event) {
const index = (ev.target as any).index;
const items = [...this._items];
items.splice(index, 1);
this._fireChanged(items);
}
private _fireChanged(value) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.row {
margin-bottom: 8px;
}
ha-textfield {
display: block;
}
ha-icon-button {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-multi-textfield": HaMultiTextField;
}
}

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;
}
}

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);
}

View File

@@ -120,14 +120,12 @@ export class HaPickerField extends PickerMixin(LitElement) {
return [
css`
ha-combo-box-item[disabled] {
background-color: var(
--mdc-text-field-disabled-fill-color,
whitesmoke
);
background-color: var(--ha-color-form-background-disabled);
cursor: not-allowed;
}
ha-combo-box-item {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
background-color: var(--ha-color-form-background);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: 0;
border-end-start-radius: 0;

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;

View File

@@ -3,13 +3,10 @@ import { customElement, property } from "lit/decorators";
import { hex2rgb, rgb2hex } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import type { ColorRGBSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-textfield";
import "../input/ha-input";
@customElement("ha-selector-color_rgb")
export class HaColorRGBSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ColorRGBSelector;
@property() public value?: string;
@@ -24,16 +21,15 @@ export class HaColorRGBSelector extends LitElement {
protected render() {
return html`
<ha-textfield
<ha-input
type="color"
helperPersistent
.value=${this.value ? rgb2hex(this.value as any) : ""}
.label=${this.label || ""}
.required=${this.required}
.helper=${this.helper}
.hint=${this.helper}
.disabled=${this.disabled}
@change=${this._valueChanged}
></ha-textfield>
></ha-input>
`;
}
@@ -50,14 +46,10 @@ export class HaColorRGBSelector extends LitElement {
justify-content: flex-end;
align-items: center;
}
ha-textfield {
--text-field-padding-top: 8px;
--text-field-padding-bottom: 8px;
--text-field-padding-start: 8px;
--text-field-padding-end: 8px;
ha-input {
min-width: 75px;
flex-grow: 1;
margin: 0 4px;
margin: 0 var(--ha-space-1);
}
`;
}

View File

@@ -1,19 +1,15 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
import type { NumberSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-input-helper-text";
import "../ha-slider";
import "../ha-textfield";
import type { HaTextField } from "../ha-textfield";
import "../input/ha-input";
import type { HaInput } from "../input/ha-input";
@customElement("ha-selector-number")
export class HaNumberSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: NumberSelector;
@property({ type: Number }) public value?: number;
@@ -31,7 +27,7 @@ export class HaNumberSelector extends LitElement {
@property({ type: Boolean }) public disabled = false;
@query("ha-textfield", true) private _input?: HaTextField | HTMLInputElement;
@query("ha-input", true) private _input?: HaInput;
private _valueStr = "";
@@ -99,29 +95,30 @@ export class HaNumberSelector extends LitElement {
</ha-slider>
`
: nothing}
<ha-textfield
<ha-input
.inputMode=${this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
: "numeric"}
.label=${!isBox ? undefined : this.label}
.placeholder=${this.placeholder}
class=${classMap({ single: isBox })}
.placeholder=${this.placeholder !== undefined
? this.placeholder.toString()
: ""}
class=${isBox ? "single" : ""}
.min=${this.selector.number?.min}
.max=${this.selector.number?.max}
.value=${this._valueStr ?? ""}
.step=${this.selector.number?.step ?? 1}
helperPersistent
.helper=${isBox ? this.helper : undefined}
.hint=${isBox ? this.helper : undefined}
.disabled=${this.disabled}
.required=${this.required}
.suffix=${unit}
type="number"
autoValidate
?no-spinner=${!isBox}
.withoutSpinButtons=${!isBox}
@input=${this._handleInputChange}
>
</ha-textfield>
${unit ? html`<span slot="end">${unit}</span>` : nothing}
</ha-input>
</div>
${!isBox && this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
@@ -166,11 +163,10 @@ export class HaNumberSelector extends LitElement {
margin-inline-end: 16px;
margin-inline-start: 0;
}
ha-textfield {
--ha-textfield-input-width: 40px;
ha-input::part(wa-input) {
width: 40px;
}
.single {
--ha-textfield-input-width: unset;
ha-input.single {
flex: 1;
}
`;

View File

@@ -0,0 +1,506 @@
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 { fireEvent } from "../../common/dom/fire_event";
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";
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;
number?: number;
entity?: string;
unit_of_measurement?: string;
}
interface NumericThresholdValue {
type: ThresholdType;
value?: ThresholdValueEntry;
value_min?: ThresholdValueEntry;
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;
@property({ attribute: false }) public selector!: NumericThresholdSelector;
@property({ attribute: false }) public value?: NumericThresholdValue;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() private _type?: ThresholdType;
private _getMode(): ThresholdMode {
return this.selector.numeric_threshold?.mode ?? "crossed";
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("value") || changedProperties.has("selector")) {
const mode = this._getMode();
this._type = this.value?.type || DEFAULT_TYPE[mode];
}
}
private _getUnitOptions() {
return this.selector.numeric_threshold?.unit_of_measurement;
}
private _getEntityFilter() {
const baseFilter = this.selector.numeric_threshold?.entity;
const configuredUnits =
this.selector.numeric_threshold?.unit_of_measurement;
if (!configuredUnits) {
return baseFilter;
}
if (Array.isArray(baseFilter)) {
return baseFilter.map((f) => ({
...f,
unit_of_measurement: configuredUnits,
}));
}
if (baseFilter) {
return { ...baseFilter, unit_of_measurement: configuredUnits };
}
return { unit_of_measurement: configuredUnits };
}
protected render() {
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 = this._buildTypeOptions(this.hass.localize, mode);
const choiceToggleButtons = [
{
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.number"
),
value: "number",
},
{
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.entity"
),
value: "entity",
},
];
// 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
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
: nothing}
<div class="inputs">
<ha-select
.label=${typeSelectLabel}
.value=${type}
.options=${typeOptions}
.disabled=${this.disabled}
@selected=${this._typeChanged}
></ha-select>
${showSingleValue
? this._renderValueRow(
singleValueLabel,
this.value?.value,
this._valueChanged,
this._valueChoiceChanged,
this._unitChanged,
unitOptions,
choiceToggleButtons
)
: nothing}
${showRangeValues
? html`
${this._renderValueRow(
this.hass.localize(
"ui.components.selectors.numeric_threshold.from"
),
this.value?.value_min,
this._valueMinChanged,
this._valueMinChoiceChanged,
this._unitMinChanged,
unitOptions,
choiceToggleButtons
)}
${this._renderValueRow(
this.hass.localize(
"ui.components.selectors.numeric_threshold.to"
),
this.value?.value_max,
this._valueMaxChanged,
this._valueMaxChoiceChanged,
this._unitMaxChanged,
unitOptions,
choiceToggleButtons
)}
`
: nothing}
</div>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing}
</div>
`;
}
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",
label: localize(
"ui.components.selectors.numeric_threshold.changed.any"
),
},
...baseOptions,
];
}
);
private _renderUnitSelect(
entry: ThresholdValueEntry | undefined,
handler: (ev: CustomEvent) => void,
unitOptions: readonly string[]
) {
if (unitOptions.length <= 1) {
return nothing;
}
const mappedUnitOptions = unitOptions.map((unit) => ({
value: unit,
label: unit,
}));
const unitLabel = this.hass.localize(
"ui.components.selectors.numeric_threshold.unit"
);
return html`
<ha-select
class="unit-selector"
.label=${unitLabel}
.value=${entry?.unit_of_measurement || unitOptions[0]}
.options=${mappedUnitOptions}
.disabled=${this.disabled}
@selected=${handler}
></ha-select>
`;
}
private _renderValueRow(
rowLabel: string,
entry: ThresholdValueEntry | undefined,
onValueChanged: (ev: CustomEvent) => void,
onChoiceChanged: (ev: CustomEvent) => void,
onUnitChanged: (ev: CustomEvent) => void,
unitOptions: readonly string[] | undefined,
choiceToggleButtons: { label: string; value: string }[]
) {
const activeChoice = entry?.active_choice ?? "number";
const isEntity = activeChoice === "entity";
const showUnit = !isEntity && !!unitOptions && unitOptions.length > 1;
const innerValue = isEntity ? entry?.entity : entry?.number;
const effectiveUnit = entry?.unit_of_measurement || unitOptions?.[0];
const numberSelector = {
number: {
...this.selector.numeric_threshold?.number,
...(effectiveUnit ? { unit_of_measurement: effectiveUnit } : {}),
},
};
const entitySelector = {
entity: {
filter: this._getEntityFilter(),
},
};
const innerSelector = isEntity ? entitySelector : numberSelector;
return html`
<div class="value-row">
<div class="value-header">
${rowLabel
? html`<span class="value-label"
>${rowLabel}${this.required ? "*" : ""}</span
>`
: nothing}
<ha-button-toggle-group
size="small"
.buttons=${choiceToggleButtons}
.active=${activeChoice}
.disabled=${this.disabled}
@value-changed=${onChoiceChanged}
></ha-button-toggle-group>
</div>
<div class="value-inputs">
<ha-selector
class="value-selector"
.hass=${this.hass}
.selector=${innerSelector}
.value=${innerValue}
.disabled=${this.disabled}
.required=${this.required}
@value-changed=${onValueChanged}
></ha-selector>
${showUnit
? this._renderUnitSelect(entry, onUnitChanged, unitOptions!)
: nothing}
</div>
</div>
`;
}
private _typeChanged(ev: CustomEvent) {
const value = ev.detail?.value;
if (!value || value === this._type) {
return;
}
this._type = value as ThresholdType;
const newValue: NumericThresholdValue = {
type: this._type,
};
// Preserve values when switching between similar types
if (this._type === "above" || this._type === "below") {
newValue.value = this.value?.value ?? this.value?.value_min;
} else if (this._type === "between" || this._type === "outside") {
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 });
}
private _choiceChanged(
field: "value" | "value_min" | "value_max",
ev: CustomEvent
) {
ev.stopPropagation();
const choice = ev.detail?.value as string;
const defaultUnit = this._getUnitOptions()?.[0];
const entry: ThresholdValueEntry = {
...this.value?.[field],
active_choice: choice,
};
if (choice !== "entity" && !entry.unit_of_measurement && defaultUnit) {
entry.unit_of_measurement = defaultUnit;
}
const defaultType = field === "value" ? "above" : "between";
fireEvent(this, "value-changed", {
value: {
...this.value,
type: this._type || defaultType,
[field]: entry,
...(field === "value"
? { value_min: undefined, value_max: undefined }
: { value: undefined }),
},
});
}
private _valueChoiceChanged = (ev: CustomEvent) =>
this._choiceChanged("value", ev);
private _valueMinChoiceChanged = (ev: CustomEvent) =>
this._choiceChanged("value_min", ev);
private _valueMaxChoiceChanged = (ev: CustomEvent) =>
this._choiceChanged("value_max", ev);
// Called when the inner number/entity selector value changes
private _entryChanged(
field: "value" | "value_min" | "value_max",
ev: CustomEvent
) {
ev.stopPropagation();
const activeChoice = this.value?.[field]?.active_choice ?? "number";
const defaultUnit = this._getUnitOptions()?.[0];
const entry: ThresholdValueEntry = {
...this.value?.[field],
active_choice: activeChoice,
[activeChoice]: ev.detail.value,
};
if (
activeChoice !== "entity" &&
!entry.unit_of_measurement &&
defaultUnit
) {
entry.unit_of_measurement = defaultUnit;
}
const defaultType = field === "value" ? "above" : "between";
fireEvent(this, "value-changed", {
value: {
...this.value,
type: this._type || defaultType,
[field]: entry,
...(field === "value"
? { value_min: undefined, value_max: undefined }
: { value: undefined }),
},
});
}
private _valueChanged = (ev: CustomEvent) => this._entryChanged("value", ev);
private _valueMinChanged = (ev: CustomEvent) =>
this._entryChanged("value_min", ev);
private _valueMaxChanged = (ev: CustomEvent) =>
this._entryChanged("value_max", ev);
private _unitFieldChanged(
field: "value" | "value_min" | "value_max",
ev: CustomEvent
) {
const unit = ev.detail?.value;
if (unit === this.value?.[field]?.unit_of_measurement) return;
const activeChoice = this.value?.[field]?.active_choice ?? "number";
const defaultType = field === "value" ? "above" : "between";
fireEvent(this, "value-changed", {
value: {
...this.value,
type: this._type || defaultType,
[field]: {
...this.value?.[field],
active_choice: activeChoice,
unit_of_measurement: unit || undefined,
},
},
});
}
private _unitChanged = (ev: CustomEvent) =>
this._unitFieldChanged("value", ev);
private _unitMinChanged = (ev: CustomEvent) =>
this._unitFieldChanged("value_min", ev);
private _unitMaxChanged = (ev: CustomEvent) =>
this._unitFieldChanged("value_max", ev);
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: 500;
margin-bottom: var(--ha-space-1);
}
.inputs,
.value-row {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.value-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.value-label {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.value-inputs {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
}
.value-selector {
flex: 1;
display: block;
}
.unit-selector {
width: 120px;
}
ha-select {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-numeric_threshold": HaNumericThresholdSelector;
}
}

View File

@@ -257,6 +257,7 @@ export class HaObjectSelector extends LitElement {
schema: this._schema(this.selector),
data: item,
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.save"),
});

View File

@@ -1,14 +1,12 @@
import { mdiEye, mdiEyeOff } from "@mdi/js";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { StringSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-icon-button";
import "../ha-multi-textfield";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-textarea";
import "../ha-textfield";
import "../input/ha-input";
import "../input/ha-input-multi";
@customElement("ha-selector-text")
export class HaTextSelector extends LitElement {
@@ -30,9 +28,7 @@ export class HaTextSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@state() private _unmaskedPassword = false;
@query("ha-textfield, ha-textarea") private _input?: HTMLInputElement;
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
public async focus() {
await this.updateComplete;
@@ -49,8 +45,7 @@ export class HaTextSelector extends LitElement {
protected render() {
if (this.selector.text?.multiple) {
return html`
<ha-multi-textfield
.hass=${this.hass}
<ha-input-multi
.value=${ensureArray(this.value ?? [])}
.disabled=${this.disabled}
.label=${this.label}
@@ -61,7 +56,7 @@ export class HaTextSelector extends LitElement {
.autocomplete=${this.selector.text?.autocomplete}
@value-changed=${this._handleChange}
>
</ha-multi-textfield>
</ha-input-multi>
`;
}
if (this.selector.text?.multiline) {
@@ -81,45 +76,34 @@ export class HaTextSelector extends LitElement {
autogrow
></ha-textarea>`;
}
return html`<ha-textfield
.name=${this.name}
.value=${this.value || ""}
.placeholder=${this.placeholder || ""}
.helper=${this.helper}
helperPersistent
.disabled=${this.disabled}
.type=${this._unmaskedPassword ? "text" : this.selector.text?.type}
@input=${this._handleChange}
@change=${this._handleChange}
.label=${this.label || ""}
.prefix=${this.selector.text?.prefix}
.suffix=${this.selector.text?.type === "password"
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.selector.text?.suffix}
.required=${this.required}
.autocomplete=${this.selector.text?.autocomplete}
></ha-textfield>
${this.selector.text?.type === "password"
? html`<ha-icon-button
.label=${this.hass?.localize(
this._unmaskedPassword
? "ui.components.selectors.text.hide_password"
: "ui.components.selectors.text.show_password"
) || (this._unmaskedPassword ? "Hide password" : "Show password")}
@click=${this._toggleUnmaskedPassword}
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-icon-button>`
: ""}`;
return html`<ha-input
.name=${this.name}
.value=${this.value || ""}
.placeholder=${this.placeholder || ""}
.hint=${this.helper}
.disabled=${this.disabled}
.type=${this.selector.text?.type}
@input=${this._handleChange}
@change=${this._handleChange}
.label=${this.label || ""}
.required=${this.required}
.autocomplete=${this.selector.text?.autocomplete}
.passwordToggle=${this.selector.text?.type === "password"}
>
${this.selector.text?.prefix
? html`<span slot="start">${this.selector.text.prefix}</span>`
: nothing}
${this.selector.text?.suffix
? html`<span slot="end">${this.selector.text.suffix}</span>`
: nothing}
</ha-input>`;
}
private _toggleUnmaskedPassword(): void {
this._unmaskedPassword = !this._unmaskedPassword;
}
private _handleChange(ev) {
private _handleChange(ev: ValueChangedEvent<string> | InputEvent) {
ev.stopPropagation();
let value = ev.detail?.value ?? ev.target.value;
let value: string | undefined =
(ev as ValueChangedEvent<string>).detail?.value ??
(ev.target as HTMLInputElement).value;
if (this.value === value) {
return;
}
@@ -139,20 +123,9 @@ export class HaTextSelector extends LitElement {
position: relative;
}
ha-textarea,
ha-textfield {
ha-input {
width: 100%;
}
ha-icon-button {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--ha-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}

View File

@@ -17,6 +17,8 @@ export class HaSelectorUiColor extends LitElement {
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-color-picker
@@ -24,9 +26,11 @@ export class HaSelectorUiColor extends LitElement {
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.disabled=${this.disabled}
.includeNone=${this.selector.ui_color?.include_none}
.includeState=${this.selector.ui_color?.include_state}
.defaultColor=${this.selector.ui_color?.default_color}
.extraOptions=${this.selector.ui_color?.extra_options}
@value-changed=${this._valueChanged}
></ha-color-picker>
`;

View File

@@ -39,6 +39,7 @@ const LOAD_ELEMENTS = {
language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"),
number: () => import("./ha-selector-number"),
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
object: () => import("./ha-selector-object"),
qr_code: () => import("./ha-selector-qr-code"),
select: () => import("./ha-selector-select"),

View File

@@ -14,6 +14,8 @@ export class HaSettingsRow extends LitElement {
@property({ type: Boolean, attribute: "wrap-heading", reflect: true })
public wrapHeading = false;
@property({ type: Boolean, reflect: true }) public empty = false;
protected render(): TemplateResult {
return html`
<div class="prefix-wrap">
@@ -27,25 +29,30 @@ export class HaSettingsRow extends LitElement {
<div class="secondary"><slot name="description"></slot></div>
</div>
</div>
<div class="content"><slot></slot></div>
<div class="content">
<slot></slot>
</div>
`;
}
static styles = css`
:host {
display: flex;
padding: 0 16px;
padding: 0 var(--ha-space-4);
align-content: normal;
align-self: auto;
align-items: center;
}
.body {
padding-top: 8px;
padding-bottom: 8px;
padding-top: var(--settings-row-body-padding-top, var(--ha-space-2));
padding-bottom: var(
--settings-row-body-padding-bottom,
var(--ha-space-2)
);
padding-left: 0;
padding-inline-start: 0;
padding-right: 16px;
padding-inline-end: 16px;
padding-right: var(--ha-space-4);
padding-inline-end: var(--ha-space-4);
overflow: hidden;
display: var(--layout-vertical_-_display, flex);
flex-direction: var(--layout-vertical_-_flex-direction, column);
@@ -63,7 +70,7 @@ export class HaSettingsRow extends LitElement {
}
.body > .secondary {
display: block;
padding-top: 4px;
padding-top: var(--ha-space-1);
font-family: var(
--mdc-typography-body2-font-family,
var(--mdc-typography-font-family, var(--ha-font-family-body))
@@ -90,7 +97,10 @@ export class HaSettingsRow extends LitElement {
justify-content: flex-end;
flex: 1;
min-width: 0;
padding: 16px 0;
padding: var(--settings-row-content-padding-block, var(--ha-space-4)) 0;
}
:host([empty]) .content {
display: none;
}
.content ::slotted(*) {
width: var(--settings-row-content-width);
@@ -99,16 +109,16 @@ export class HaSettingsRow extends LitElement {
align-items: normal;
flex-direction: column;
border-top: 1px solid var(--divider-color);
padding-bottom: 8px;
padding-bottom: var(--ha-space-2);
}
::slotted(ha-switch) {
padding: 16px 0;
padding: var(--settings-row-switch-padding-block, var(--ha-space-4)) 0;
}
.secondary {
white-space: normal;
}
.prefix-wrap {
flex: 1;
flex: var(--settings-row-prefix-flex, 1);
display: var(--settings-row-prefix-display);
}
:host([narrow]) .prefix-wrap {

View File

@@ -20,6 +20,9 @@ export class HaTextArea extends TextAreaBase {
textfieldStyles,
textareaStyles,
css`
:host {
--mdc-text-field-fill-color: var(--ha-color-form-background);
}
:host([autogrow]) .mdc-text-field {
position: relative;
min-height: 74px;

View File

@@ -26,6 +26,9 @@ export class HaTimeInput extends LitElement {
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
@property({ attribute: "placeholder-labels", type: Boolean })
public placeholderLabels = false;
@query("ha-base-time-input") private _input?: HaBaseTimeInput;
public reportValidity(): boolean {
@@ -67,6 +70,7 @@ export class HaTimeInput extends LitElement {
.required=${this.required}
.clearable=${this.clearable && this.value !== undefined}
.helper=${this.helper}
.placeholderLabels=${this.placeholderLabels}
day-label="dd"
hour-label="hh"
min-label="mm"

View File

@@ -1,7 +1,10 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import "@home-assistant/webawesome/dist/components/popup/popup";
import type WaPopup from "@home-assistant/webawesome/dist/components/popup/popup";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../common/dom/fire_event";
import { nextRender } from "../common/util/render-status";
export type ToastCloseReason =
| "dismiss"
@@ -19,23 +22,100 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@query("wa-popover")
private _popover?: WaPopover;
@query("wa-popup")
private _popup?: WaPopup;
@query(".toast")
private _toast?: HTMLDivElement;
@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._popup?.reposition();
this._setDismissTimer();
return;
}
const transitionId = ++this._transitionId;
this._active = true;
await this.updateComplete;
if (transitionId !== this._transitionId) {
return;
}
this._popup?.reposition();
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._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,53 +123,53 @@ export class HaToast extends LitElement {
}
}
public async hide(reason: ToastCloseReason = "programmatic"): Promise<void> {
clearTimeout(this._dismissTimer);
this._closeReason = reason;
await this._popover?.hide();
}
private async _waitForTransitionEnd(): Promise<void> {
const toastEl = this._toast;
if (!toastEl) {
return;
}
public close(reason: ToastCloseReason = "programmatic"): void {
this.hide(reason);
}
const animations = toastEl.getAnimations({ subtree: true });
if (animations.length === 0) {
return;
}
private _handleAfterHide() {
this.dispatchEvent(
new CustomEvent<ToastClosedEventDetail>("toast-closed", {
detail: { reason: this._closeReason },
bubbles: true,
composed: true,
})
);
this._closeReason = "programmatic";
await Promise.allSettled(animations.map((animation) => animation.finished));
}
protected render() {
return html`
<div id="toast-anchor" aria-hidden="true"></div>
<wa-popover
for="toast-anchor"
<wa-popup
placement="top"
distance="16"
.active=${this._active}
.distance=${16}
skidding="0"
without-arrow
@wa-after-hide=${this._handleAfterHide}
flip
shift
>
<div class="toast" role="status" aria-live="polite">
<div id="toast-anchor" slot="anchor" aria-hidden="true"></div>
<div
class=${classMap({
toast: true,
visible: this._visible,
})}
role="status"
aria-live="polite"
>
<span class="message">${this.labelText}</span>
<div class="actions">
<slot name="action"></slot>
<slot name="dismiss"></slot>
</div>
</div>
</wa-popover>
</wa-popup>
`;
}
static override styles = css`
#toast-anchor {
position: fixed;
bottom: calc(8px + var(--safe-area-inset-bottom));
bottom: calc(var(--ha-space-2) + var(--safe-area-inset-bottom));
inset-inline-start: 50%;
transform: translateX(-50%);
width: 1px;
@@ -98,22 +178,11 @@ export class HaToast extends LitElement {
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) {
wa-popup::part(popup) {
padding: 0;
border-radius: 4px;
border-radius: var(--ha-border-radius-sm);
box-shadow: var(--wa-shadow-l);
overflow: hidden;
}
.toast {
@@ -121,8 +190,9 @@ export class HaToast extends LitElement {
min-width: min(
350px,
calc(
100vw -
16px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
100vw - var(--ha-space-4) - var(--safe-area-inset-left) - var(
--safe-area-inset-right
)
)
);
max-width: 650px;
@@ -131,8 +201,19 @@ export class HaToast extends LitElement {
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);
color: var(--ha-color-on-neutral-loud);
background-color: var(--ha-color-neutral-10);
border-radius: var(--ha-border-radius-sm);
opacity: 0;
transform: translateY(var(--ha-space-2));
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease,
transform var(--ha-animation-duration-fast, 150ms) ease;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
}
.message {
@@ -144,23 +225,27 @@ 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);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
wa-popup::part(popup) {
border-radius: var(--ha-border-radius-square);
}
.toast {
min-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right)
);
border-radius: 0;
border-radius: var(--ha-border-radius-square);
}
}
`;
}
declare global {
interface HTMLElementEventMap {
"toast-closed": CustomEvent<ToastClosedEventDetail>;
interface HASSDomEvents {
"toast-closed": ToastClosedEventDetail;
}
interface HTMLElementTagNameMap {

View File

@@ -0,0 +1,155 @@
import { consume, type ContextType } from "@lit/context";
import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import { localizeContext } from "../../data/context";
import { showToast } from "../../util/toast";
import "../ha-button";
import "../ha-icon-button";
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;
@property({ attribute: "masked-value" }) public maskedValue?: string;
@property({ attribute: "label" }) public label?: string;
@property({ type: Boolean }) public readonly = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: "masked-toggle" }) public maskedToggle =
false;
@property() public type: InputType = "text";
@property()
public placeholder = "";
@property({ attribute: "validation-message" })
public validationMessage?: string;
@property({ type: Boolean, attribute: "auto-validate" }) public autoValidate =
false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state() private _showMasked = true;
@query("ha-input", true) private _inputElement?: HaInput;
public reportValidity(): boolean {
return this._inputElement?.reportValidity() ?? false;
}
public render() {
return html`
<div class="container">
<div class="textfield-container">
<ha-input
.type=${this.type}
.value=${this._showMasked && this.maskedValue
? this.maskedValue
: this.value}
.readonly=${this.readonly}
.disabled=${this.disabled}
@click=${this._focusInput}
.placeholder=${this.placeholder}
.autoValidate=${this.autoValidate}
.validationMessage=${this.validationMessage}
>
${this.maskedToggle && this.maskedValue
? html`<ha-icon-button
slot="end"
class="toggle-unmasked"
.label=${this.localize(
`ui.common.${this._showMasked ? "show" : "hide"}`
)}
@click=${this._toggleMasked}
.path=${this._showMasked ? mdiEye : mdiEyeOff}
></ha-icon-button>`
: nothing}
</ha-input>
</div>
<ha-button @click=${this._copy} appearance="plain" size="small">
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
${this.label || this.localize("ui.common.copy")}
</ha-button>
</div>
`;
}
private _focusInput(ev: Event) {
const inputElement = ev.currentTarget as HaInput;
inputElement.select();
}
private _toggleMasked(): void {
this._showMasked = !this._showMasked;
}
private async _copy(): Promise<void> {
await copyToClipboard(this.value);
showToast(this, {
message: this.localize("ui.common.copied_clipboard"),
});
}
static styles = css`
.container {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: 8px;
}
.textfield-container {
position: relative;
flex: 1;
}
.toggle-unmasked {
--ha-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
}
ha-button {
margin-bottom: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-input-copy": HaInputCopy;
}
}

View File

@@ -0,0 +1,238 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { localizeContext } from "../../data/context";
import { haStyle } from "../../resources/styles";
import "../ha-button";
import "../ha-icon-button";
import "../ha-input-helper-text";
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[];
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property() public helper?: string;
@property({ attribute: "input-type" }) public inputType?: InputType;
@property({ attribute: "input-suffix" }) public inputSuffix?: string;
@property({ attribute: "input-prefix" }) public inputPrefix?: string;
@property() public autocomplete?: string;
@property({ attribute: "add-label" }) public addLabel?: string;
@property({ attribute: "remove-label" }) public removeLabel?: string;
@property({ attribute: "item-index", type: Boolean })
public itemIndex = false;
@property({ type: Number }) public max?: number;
@property({ type: Boolean }) public sortable = false;
@property({ type: Boolean, attribute: "update-on-blur" })
public updateOnBlur = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize?: ContextType<typeof localizeContext>;
protected render() {
return html`
<ha-sortable
handle-selector=".handle"
draggable-selector=".row"
.disabled=${!this.sortable || this.disabled}
@item-moved=${this._itemMoved}
>
<div class="items">
${repeat(
this._items,
(item, index) => `${item}-${index}`,
(item, index) => {
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
return html`
<div class="layout horizontal center-center row">
<ha-input
.type=${this.inputType}
.autocomplete=${this.autocomplete}
.disabled=${this.disabled}
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
.label=${`${this.label ? `${this.label}${indexSuffix}` : ""}`}
.value=${item}
?data-last=${index === this._items.length - 1}
@input=${this._editItem}
@change=${this._editItem}
@keydown=${this._keyDown}
>
${this.inputPrefix
? html`<span slot="start">${this.inputPrefix}</span>`
: nothing}
${this.inputSuffix
? html`<span slot="end">${this.inputSuffix}</span>`
: nothing}
</ha-input>
<ha-icon-button
.disabled=${this.disabled}
.index=${index}
slot="navigationIcon"
.label=${this.removeLabel ??
this.localize?.("ui.common.remove") ??
"Remove"}
@click=${this._removeItem}
.path=${mdiDeleteOutline}
></ha-icon-button>
${this.sortable
? html`<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>`
: nothing}
</div>
`;
}
)}
</div>
</ha-sortable>
<div class="layout horizontal">
<ha-button
size="small"
appearance="filled"
@click=${this._addItem}
.disabled=${this.disabled ||
(this.max != null && this._items.length >= this.max)}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.addLabel ??
(this.label
? this.localize?.("ui.components.multi-textfield.add_item", {
item: this.label,
})
: this.localize?.("ui.common.add")) ??
"Add"}
</ha-button>
</div>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`;
}
private get _items() {
return this.value ?? [];
}
private async _addItem() {
if (this.max != null && this._items.length >= this.max) {
return;
}
const items = [...this._items, ""];
this._fireChanged(items);
await this.updateComplete;
const field = this.shadowRoot?.querySelector(`ha-input[data-last]`) as
| HaInput
| undefined;
field?.focus();
}
private async _editItem(ev: Event) {
if (this.updateOnBlur && ev.type === "input") {
return;
}
if (!this.updateOnBlur && ev.type === "change") {
return;
}
const index = (ev.target as any).index;
const items = [...this._items];
items[index] = (ev.target as any).value;
this._fireChanged(items);
}
private async _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
ev.stopPropagation();
this._addItem();
}
}
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const items = [...this._items];
const [moved] = items.splice(oldIndex, 1);
items.splice(newIndex, 0, moved);
this._fireChanged(items);
}
private async _removeItem(ev: Event) {
const index = (ev.target as any).index;
const items = [...this._items];
items.splice(index, 1);
this._fireChanged(items);
}
private _fireChanged(value) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.row {
margin-bottom: 8px;
--ha-input-padding-bottom: 0;
}
ha-icon-button {
display: block;
}
.handle {
cursor: grab;
padding: 8px;
margin: -8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-input-multi": HaInputMulti;
}
}

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;
}
}

View File

@@ -3,37 +3,89 @@ 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 =
| "date"
| "datetime-local"
| "email"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "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:
| "date"
| "datetime-local"
| "email"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "url" = "text";
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 = "";
@@ -271,7 +323,7 @@ export class HaInput extends LitElement {
.type=${this.type}
.value=${this.value ?? null}
.withClear=${this.withClear}
.placeholder=${this.placeholder && this.label ? this.placeholder : ""}
.placeholder=${this.placeholder}
.readonly=${this.readonly}
.passwordToggle=${this.passwordToggle}
.passwordVisible=${this.passwordVisible}
@@ -294,7 +346,16 @@ export class HaInput extends LitElement {
.disabled=${this.disabled}
class=${classMap({
invalid: this.invalid || this._invalid,
"label-raised": this.value || this.placeholder,
"label-raised":
(this.value !== undefined && this.value !== "") ||
(this.label && this.placeholder),
"no-label": !this.label,
"hint-hidden":
!this.hint &&
!hasHintSlot &&
!this.required &&
!this._invalid &&
!this.invalid,
})}
@input=${this._handleInput}
@change=${this._handleChange}
@@ -304,19 +365,13 @@ export class HaInput extends LitElement {
>
${this.label || hasLabelSlot
? html`<slot name="label" slot="label"
>${this._renderLabel(
this.label,
this.placeholder,
this.required
)}</slot
>${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>
@@ -334,7 +389,9 @@ export class HaInput extends LitElement {
</slot>
<div
slot="hint"
class=${this.invalid || this._invalid ? "error" : ""}
class=${classMap({
error: this.invalid || this._invalid,
})}
role=${ifDefined(this.invalid || this._invalid ? "alert" : undefined)}
aria-live="polite"
>
@@ -347,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()) {
@@ -388,46 +453,44 @@ export class HaInput extends LitElement {
}
};
private _renderLabel = memoizeOne(
(label: string, placeholder: string, required: boolean) => {
// fallback to placeholder if no label is provided
const text = label || placeholder;
if (!required) {
return text;
}
let marker = getComputedStyle(this).getPropertyValue(
"--ha-input-required-marker"
);
if (!marker) {
marker = "*";
}
if (marker.startsWith('"') && marker.endsWith('"')) {
marker = marker.slice(1, -1);
}
if (!marker) {
return text;
}
return `${text}${marker}`;
private _renderLabel = memoizeOne((label: string, required: boolean) => {
if (!required) {
return label;
}
);
let marker = getComputedStyle(this).getPropertyValue(
"--ha-input-required-marker"
);
if (!marker) {
marker = "*";
}
if (marker.startsWith('"') && marker.endsWith('"')) {
marker = marker.slice(1, -1);
}
if (!marker) {
return label;
}
return `${label}${marker}`;
});
static styles = css`
:host {
display: flex;
align-items: flex-start;
padding-top: var(--ha-input-padding-top, var(--ha-space-2));
padding-top: var(--ha-input-padding-top);
padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2));
text-align: var(--ha-input-text-align, start);
}
:host([appearance="outlined"]) {
padding-bottom: var(--ha-input-padding-bottom);
}
wa-input {
flex: 1;
min-width: 0;
height: 76px;
--wa-transition-fast: var(--wa-transition-normal);
position: relative;
}
@@ -441,6 +504,7 @@ export class HaInput extends LitElement {
color: var(--secondary-text-color);
line-height: var(--ha-line-height-condensed);
z-index: 1;
pointer-events: none;
padding-inline-start: calc(
var(--start-slot-width, 0px) + var(--ha-space-4)
);
@@ -449,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);
}
@@ -459,14 +523,15 @@ 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);
}
wa-input::part(base) {
height: 56px;
background-color: var(--ha-color-fill-neutral-quiet-resting);
background-color: var(--ha-color-form-background);
border-top-left-radius: var(--ha-border-radius-sm);
border-top-right-radius: var(--ha-border-radius-sm);
border-bottom-left-radius: var(--ha-border-radius-square);
@@ -477,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;
@@ -490,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);
}
@@ -504,8 +583,19 @@ 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;
}
:host([type="color"]) wa-input::part(input) {
padding-top: var(--ha-space-6);
cursor: pointer;
}
:host([type="color"]) wa-input.no-label::part(input) {
padding: var(--ha-space-2);
}
:host([type="color"]) wa-input.no-label::part(base) {
padding: 0;
}
wa-input::part(input)::placeholder {
color: var(--ha-color-neutral-60);
@@ -516,11 +606,18 @@ export class HaInput extends LitElement {
}
wa-input::part(base):hover {
background-color: var(--ha-color-fill-neutral-quiet-hover);
background-color: var(--ha-color-form-background-hover);
}
:host([appearance="outlined"]) wa-input::part(base):hover {
border-color: var(--ha-color-border-neutral-normal);
}
:host([appearance="outlined"]:focus-within) wa-input::part(base) {
border-color: var(--primary-color);
}
wa-input:disabled::part(base) {
background-color: var(--ha-color-fill-disabled-quiet-resting);
background-color: var(--ha-color-form-background-disabled);
}
wa-input::part(hint) {
@@ -533,6 +630,10 @@ export class HaInput extends LitElement {
color: var(--ha-color-text-secondary);
}
wa-input.hint-hidden::part(hint) {
height: 0;
}
.error {
color: var(--ha-color-on-danger-quiet);
}
@@ -540,6 +641,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;
}
`;
}

View File

@@ -54,10 +54,19 @@ export interface HaMapPaths {
fullDatetime?: boolean;
}
export const MAP_CARD_MARKER_LABEL_MODES = [
"name",
"state",
"attribute",
"icon",
] as const;
export type MapCardMarkerLabelMode =
(typeof MAP_CARD_MARKER_LABEL_MODES)[number];
export interface HaMapEntity {
entity_id: string;
color: string;
label_mode?: "name" | "state" | "attribute" | "icon";
label_mode?: MapCardMarkerLabelMode;
attribute?: string;
unit?: string;
name?: string;

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;
}
}

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;
}
}

View File

@@ -761,6 +761,7 @@ export class HatScriptGraph extends LitElement {
display: flex;
flex-direction: column;
align-items: center;
min-width: fit-content;
}
.actions {
display: flex;

View File

@@ -117,6 +117,7 @@ interface PipelineIntentStartEvent extends PipelineEventBase {
export interface ConversationChatLogAssistantDelta {
role: "assistant";
content: string;
thinking_content?: string;
tool_calls: {
id: string;
tool_name: string;

View File

@@ -3,27 +3,30 @@ import {
addHours,
addMilliseconds,
addMonths,
addYears,
differenceInDays,
differenceInMonths,
endOfDay,
startOfDay,
isFirstDayOfMonth,
isLastDayOfMonth,
addYears,
startOfDay,
} from "date-fns";
import type { Collection, HassEntity } from "home-assistant-js-websocket";
import { getCollection } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import {
calcDate,
calcDateProperty,
calcDateDifferenceProperty,
calcDateProperty,
} from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
import { fileDownload } from "../util/file_download";
import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download";
import type {
Statistics,
StatisticsMetaData,
@@ -36,9 +39,6 @@ import {
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
import { calcDateRange } from "../common/datetime/calc_date_range";
import type { DateRange } from "../common/datetime/calc_date_range";
import { formatNumber } from "../common/number/format_number";
export const ENERGY_COLLECTION_KEY_PREFIX = "energy_";
@@ -841,7 +841,7 @@ export const getEnergyDataCollection = (
const period =
preferredPeriod === "today" && hour === "0" ? "yesterday" : preferredPeriod;
const [start, end] = calcDateRange(hass, period);
const [start, end] = calcDateRange(hass.locale, hass.config, period);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
@@ -1461,7 +1461,7 @@ export const calculateSolarConsumedGauge = (
/** Exact number of liters in one US gallon */
const LITERS_PER_GALLON = 3.785411784;
const FLOW_RATE_TO_LMIN: Record<string, number> = {
export const FLOW_RATE_TO_LMIN: Record<string, number> = {
"m³/h": 1000 / 60,
"m³/min": 1000,
"m³/s": 60000,
@@ -1498,6 +1498,75 @@ export const getFlowRateFromState = (
return value * factor;
};
/**
* Compute the total flow rate across all energy sources of a given type.
* Used by gas and water total badges.
*/
export const computeTotalFlowRate = (
sourceType: "gas" | "water",
prefs: EnergyPreferences,
states: HomeAssistant["states"],
entities: Set<string>
): { value: number; unit: string } => {
entities.clear();
let targetUnit: string | undefined;
let totalFlow = 0;
prefs.energy_sources.forEach((source) => {
if (source.type !== sourceType || !source.stat_rate) {
return;
}
const entityId = source.stat_rate;
entities.add(entityId);
const stateObj = states[entityId];
if (!stateObj) {
return;
}
let rawValue = parseFloat(stateObj.state);
if (isNaN(rawValue)) {
return;
}
if (rawValue < 0) {
rawValue = 0;
}
const entityUnit = stateObj.attributes.unit_of_measurement;
if (!entityUnit) {
return;
}
if (targetUnit === undefined) {
targetUnit = entityUnit;
totalFlow += rawValue;
return;
}
if (entityUnit === targetUnit) {
totalFlow += rawValue;
return;
}
const sourceFactor = FLOW_RATE_TO_LMIN[entityUnit];
const targetFactor = FLOW_RATE_TO_LMIN[targetUnit];
if (sourceFactor !== undefined && targetFactor !== undefined) {
totalFlow += (rawValue * sourceFactor) / targetFactor;
} else {
totalFlow += rawValue;
}
});
return {
value: Math.max(0, totalFlow),
unit: targetUnit ?? "",
};
};
/**
* Format a flow rate value (in L/min) to a human-readable string using
* the preferred unit system: metric → L/min, imperial → gal/min.

View File

@@ -92,16 +92,24 @@ export interface LightEntityOptions {
favorite_colors?: LightColor[];
}
export interface ValveEntityOptions {
favorite_positions?: number[];
}
export type FavoriteOption =
| "favorite_colors"
| "favorite_positions"
| "favorite_tilt_positions";
export type FavoritesDomain = "light" | "cover";
export type FavoritesDomain = "light" | "cover" | "valve";
export type FavoriteOptionValue = LightColor[] | number[];
export const DOMAINS_WITH_FAVORITES: FavoritesDomain[] = ["light", "cover"];
export const DOMAINS_WITH_FAVORITES: FavoritesDomain[] = [
"light",
"cover",
"valve",
];
export const isFavoritesDomain = (domain: string): domain is FavoritesDomain =>
DOMAINS_WITH_FAVORITES.includes(domain as FavoritesDomain);
@@ -162,6 +170,7 @@ export interface EntityRegistryOptions {
weather?: WeatherEntityOptions;
light?: LightEntityOptions;
cover?: CoverEntityOptions;
valve?: ValveEntityOptions;
vacuum?: VacuumEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
conversation?: Record<string, unknown>;
@@ -187,6 +196,7 @@ export interface EntityRegistryEntryUpdateParams {
| WeatherEntityOptions
| LightEntityOptions
| CoverEntityOptions
| ValveEntityOptions
| VacuumEntityOptions;
aliases?: (string | null)[];
labels?: string[];

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]
);
}
});

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,

View File

@@ -2,11 +2,19 @@ import type { Condition } from "../../../panels/lovelace/common/validate-conditi
import type { LovelaceCardConfig } from "./card";
import type { LovelaceStrategyConfig } from "./strategy";
export const DEFAULT_SECTION_BACKGROUND_OPACITY = 50;
export interface LovelaceSectionBackgroundConfig {
color?: string;
opacity?: number;
}
export interface LovelaceBaseSectionConfig {
visibility?: Condition[];
disabled?: boolean;
column_span?: number;
row_span?: number;
background?: boolean | LovelaceSectionBackgroundConfig;
/**
* @deprecated Use heading card instead.
*/
@@ -26,6 +34,15 @@ export type LovelaceSectionRawConfig =
| LovelaceSectionConfig
| LovelaceStrategySectionConfig;
export function resolveSectionBackground(
background: boolean | LovelaceSectionBackgroundConfig | undefined
): LovelaceSectionBackgroundConfig | undefined {
if (typeof background === "boolean") {
return background ? {} : undefined;
}
return background;
}
export function isStrategySection(
section: LovelaceSectionRawConfig
): section is LovelaceStrategySectionConfig {

View File

@@ -12,6 +12,7 @@ export const SCENE_IGNORED_DOMAINS = [
"device_tracker",
"event",
"image_processing",
"infrared",
"input_button",
"persistent_notification",
"person",

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
@@ -56,6 +58,7 @@ export type Selector =
| MediaSelector
| NavigationSelector
| NumberSelector
| NumericThresholdSelector
| ObjectSelector
| AssistPipelineSelector
| QRCodeSelector
@@ -241,6 +244,7 @@ interface EntitySelectorFilter {
domain?: string | readonly string[];
device_class?: string | readonly string[];
supported_features?: number | [number];
unit_of_measurement?: string | readonly string[];
}
export interface EntitySelector {
@@ -362,6 +366,15 @@ export interface NumberSelector {
} | null;
}
export interface NumericThresholdSelector {
numeric_threshold: {
mode?: ThresholdMode;
unit_of_measurement?: readonly string[];
number?: NumberSelector["number"];
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
} | null;
}
interface ObjectSelectorField {
selector: Selector;
label?: string;
@@ -506,11 +519,19 @@ export interface UiActionSelector {
} | null;
}
export interface UiColorExtraOption {
value: string;
label: string;
icon?: string;
display_color?: string;
}
export interface UiColorSelector {
ui_color: {
default_color?: string;
include_none?: boolean;
include_state?: boolean;
extra_options?: UiColorExtraOption[];
} | null;
}
@@ -811,6 +832,7 @@ export const filterSelectorEntities = (
domain: filterDomain,
device_class: filterDeviceClass,
supported_features: filterSupportedFeature,
unit_of_measurement: filterUnitOfMeasurement,
integration: filterIntegration,
} = filterEntity;
@@ -846,6 +868,18 @@ export const filterSelectorEntities = (
}
}
if (filterUnitOfMeasurement) {
const entityUnitOfMeasurement = entity.attributes.unit_of_measurement;
if (
!entityUnitOfMeasurement ||
(Array.isArray(filterUnitOfMeasurement)
? !filterUnitOfMeasurement.includes(entityUnitOfMeasurement)
: entityUnitOfMeasurement !== filterUnitOfMeasurement)
) {
return false;
}
}
if (
filterIntegration &&
entitySources?.[entity.entity_id]?.domain !== filterIntegration

View File

@@ -47,13 +47,34 @@ export interface ExtractFromTargetResultReferenced {
export const extractFromTarget = async (
hass: HomeAssistant,
target: HassServiceTarget
target: HassServiceTarget,
expandGroup = false
) =>
hass.callWS<ExtractFromTargetResult>({
type: "extract_from_target",
target,
expand_group: expandGroup,
});
export const getResolvedTargetEntityCount = async (
hass: HomeAssistant,
target?: HassServiceTarget
): Promise<number | undefined> => {
if (!target) {
return undefined;
}
try {
return (await extractFromTarget(hass, target, true)).referenced_entities
.length;
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error resolving target entity count", err);
}
return undefined;
};
export const getTriggersForTarget = async (
callWS: HomeAssistant["callWS"],
target: HassServiceTarget,

View File

@@ -3,6 +3,7 @@ import type {
HassEntityBase,
} from "home-assistant-js-websocket";
import { stateActive } from "../common/entity/state_active";
import { supportsFeature } from "../common/entity/supports-feature";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
@@ -13,6 +14,41 @@ export const enum ValveEntityFeature {
STOP = 8,
}
export const DEFAULT_VALVE_FAVORITE_POSITIONS = [0, 25, 75, 100];
export const valveSupportsPosition = (stateObj: ValveEntity) =>
supportsFeature(stateObj, ValveEntityFeature.SET_POSITION);
export const normalizeValveFavoritePositions = (
positions?: number[]
): number[] => {
if (!positions) {
return [];
}
const unique = new Set<number>();
const normalized: number[] = [];
for (const position of positions) {
const value = Number(position);
if (isNaN(value)) {
continue;
}
const clamped = Math.max(0, Math.min(100, Math.round(value)));
if (unique.has(clamped)) {
continue;
}
unique.add(clamped);
normalized.push(clamped);
}
return normalized;
};
export function isFullyOpen(stateObj: ValveEntity) {
if (
stateObj.attributes.current_position !== undefined &&

View File

@@ -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>
`;
}

View File

@@ -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;

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;
}

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>;
@@ -109,14 +109,13 @@ class DialogBox extends LitElement {
${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 +123,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 +243,7 @@ class DialogBox extends LitElement {
.secondary {
color: var(--secondary-text-color);
}
ha-textfield {
ha-input {
width: 100%;
}
.title.alert {

View File

@@ -1,4 +1,3 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -10,17 +9,10 @@ import {
temperature2rgb,
} from "../../../../common/color/convert-light-color";
import { luminosity } from "../../../../common/color/rgb";
import "../../../../components/ha-outlined-icon-button";
import type { HaOutlinedIconButton } from "../../../../components/ha-outlined-icon-button";
import "../../../../components/ha-svg-icon";
import type { LightColor, LightEntity } from "../../../../data/light";
@customElement("ha-favorite-color-button")
class MoreInfoViewLightColorPicker extends LitElement {
public override focus() {
this._button?.focus();
}
class HaFavoriteColorButton extends LitElement {
@property({ attribute: false }) label?: string;
@property({ type: Boolean, reflect: true }) disabled = false;
@@ -29,10 +21,12 @@ class MoreInfoViewLightColorPicker extends LitElement {
@property({ attribute: false }) color!: LightColor;
@property({ type: Boolean, reflect: true }) wide = false;
@query("button", true)
private _button!: HTMLButtonElement;
@query("ha-outlined-icon-button", true)
private _button?: HaOutlinedIconButton;
public override focus() {
this._button?.focus();
}
private get _rgbColor(): [number, number, number] {
if (this.color) {
@@ -63,83 +57,64 @@ class MoreInfoViewLightColorPicker extends LitElement {
protected render() {
const backgroundColor = rgb2hex(this._rgbColor);
const isLight = luminosity(this._rgbColor) > 0.8;
const iconColor = isLight
? ([33, 33, 33] as [number, number, number])
: ([255, 255, 255] as [number, number, number]);
const hexIconColor = rgb2hex(iconColor);
const rgbIconColor = iconColor.join(", ");
const borderColor = isLight ? "var(--divider-color)" : "transparent";
return html`
<ha-outlined-icon-button
no-ripple
<button
.disabled=${this.disabled}
title=${ifDefined(this.label)}
aria-label=${ifDefined(this.label)}
style=${styleMap({
"background-color": backgroundColor,
"--icon-color": hexIconColor,
"--rgb-icon-color": rgbIconColor,
"border-color": borderColor,
"--focus-color": isLight ? borderColor : backgroundColor,
})}
></ha-outlined-icon-button>
></button>
`;
}
static get styles(): CSSResultGroup {
return [
css`
:host {
display: block;
}
ha-outlined-icon-button {
--ha-icon-display: block;
--md-sys-color-on-surface: var(
--icon-color,
var(--secondary-text-color)
);
--md-sys-color-on-surface-variant: var(
--icon-color,
var(--secondary-text-color)
);
--md-sys-color-on-surface-rgb: var(
--rgb-icon-color,
var(--rgb-secondary-text-color)
);
--md-sys-color-outline: var(--divider-color);
--md-ripple-focus-color: 0;
--md-ripple-hover-opacity: 0;
--md-ripple-pressed-opacity: 0;
border-radius: var(--ha-border-radius-pill);
}
:host([wide]) ha-outlined-icon-button {
width: 100%;
border-radius: var(--ha-favorite-color-button-border-radius);
--_container-shape: var(--ha-favorite-color-button-border-radius);
--_container-shape-start-start: var(
--ha-favorite-color-button-border-radius
);
--_container-shape-start-end: var(
--ha-favorite-color-button-border-radius
);
--_container-shape-end-start: var(
--ha-favorite-color-button-border-radius
);
--_container-shape-end-end: var(
--ha-favorite-color-button-border-radius
);
}
:host([disabled]) {
pointer-events: none;
}
ha-outlined-icon-button[disabled] {
filter: grayscale(1) opacity(0.5);
}
`,
];
}
static readonly styles = css`
:host {
display: block;
width: var(--ha-favorite-color-button-size, 40px);
height: var(--ha-favorite-color-button-size, 40px);
}
button {
background-color: var(--color);
position: relative;
display: block;
width: 100%;
height: 100%;
border: 1px solid transparent;
border-radius: var(
--ha-favorite-color-button-border-radius,
var(--ha-border-radius-pill)
);
padding: 0;
margin: 0;
cursor: pointer;
outline: none;
transition:
box-shadow 180ms ease-in-out,
transform 180ms ease-in-out;
}
button:focus-visible {
box-shadow: 0 0 0 2px var(--focus-color);
}
button:active {
transform: scale(1.1);
}
:host([disabled]) {
pointer-events: none;
}
button:disabled {
filter: grayscale(1) opacity(0.5);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-favorite-color-button": MoreInfoViewLightColorPicker;
"ha-favorite-color-button": HaFavoriteColorButton;
}
}

View File

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

View File

@@ -0,0 +1,355 @@
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-control-button";
import { UNAVAILABLE } from "../../../../data/entity/entity";
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity/entity_attributes";
import type {
ExtEntityRegistryEntry,
ValveEntityOptions,
} from "../../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
import type { ValveEntity } from "../../../../data/valve";
import {
DEFAULT_VALVE_FAVORITE_POSITIONS,
normalizeValveFavoritePositions,
} from "../../../../data/valve";
import {
showConfirmationDialog,
showPromptDialog,
} from "../../../generic/show-dialog-box";
import "../ha-more-info-favorites";
import type { HaMoreInfoFavorites } from "../ha-more-info-favorites";
type FavoriteLocalizeKey =
| "set"
| "edit"
| "delete"
| "delete_confirm_title"
| "delete_confirm_text"
| "delete_confirm_action"
| "add"
| "edit_title"
| "add_title";
@customElement("ha-more-info-valve-favorite-positions")
export class HaMoreInfoValveFavoritePositions extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: ValveEntity;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public editMode?: boolean;
@state() private _favoritePositions: number[] = [];
protected updated(changedProps: PropertyValues<this>): void {
if (
(changedProps.has("entry") || changedProps.has("stateObj")) &&
this.entry &&
this.stateObj
) {
this._favoritePositions = normalizeValveFavoritePositions(
this.entry.options?.valve?.favorite_positions ??
DEFAULT_VALVE_FAVORITE_POSITIONS
);
}
}
private _localizeFavorite(
key: FavoriteLocalizeKey,
values?: Record<string, string | number>
): string {
return this.hass.localize(
`ui.dialogs.more_info_control.valve.favorite_position.${key}`,
values
);
}
private _currentValue(): number | undefined {
const current = this.stateObj.attributes.current_position;
return current == null ? undefined : Math.round(current);
}
private async _save(favorite_positions: number[]): Promise<void> {
if (!this.entry) {
return;
}
const currentOptions: ValveEntityOptions = {
...(this.entry.options?.valve ?? {}),
};
currentOptions.favorite_positions = this._favoritePositions;
const result = await updateEntityRegistryEntry(
this.hass,
this.entry.entity_id,
{
options_domain: "valve",
options: {
...currentOptions,
favorite_positions,
},
}
);
fireEvent(this, "entity-entry-updated", result.entity_entry);
}
private async _setFavorites(favorites: number[]): Promise<void> {
const normalized = normalizeValveFavoritePositions(favorites);
this._favoritePositions = normalized;
await this._save(normalized);
}
private _move(index: number, newIndex: number): void {
const favorites = this._favoritePositions.concat();
const moved = favorites.splice(index, 1)[0];
favorites.splice(newIndex, 0, moved);
this._setFavorites(favorites);
}
private _applyFavorite(index: number): void {
const favorite = this._favoritePositions[index];
if (favorite === undefined) {
return;
}
this.hass.callService("valve", "set_valve_position", {
entity_id: this.stateObj.entity_id,
position: favorite,
});
}
private async _promptFavoriteValue(
value?: number
): Promise<number | undefined> {
const response = await showPromptDialog(this, {
title: this._localizeFavorite(
value === undefined ? "add_title" : "edit_title"
),
inputLabel: this.hass.localize("ui.card.valve.position"),
inputType: "number",
inputMin: "0",
inputMax: "100",
inputSuffix: DOMAIN_ATTRIBUTES_UNITS.valve.current_position,
defaultValue: value === undefined ? undefined : String(value),
});
if (response === null || response.trim() === "") {
return undefined;
}
const number = Number(response);
if (isNaN(number)) {
return undefined;
}
return Math.max(0, Math.min(100, Math.round(number)));
}
private async _addFavorite(): Promise<void> {
const value = await this._promptFavoriteValue();
if (value === undefined) {
return;
}
await this._setFavorites([...this._favoritePositions, value]);
}
private async _editFavorite(index: number): Promise<void> {
const current = this._favoritePositions[index];
if (current === undefined) {
return;
}
const value = await this._promptFavoriteValue(current);
if (value === undefined) {
return;
}
const updated = [...this._favoritePositions];
updated[index] = value;
await this._setFavorites(updated);
}
private async _deleteFavorite(index: number): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
destructive: true,
title: this._localizeFavorite("delete_confirm_title"),
text: this._localizeFavorite("delete_confirm_text"),
confirmText: this._localizeFavorite("delete_confirm_action"),
});
if (!confirmed) {
return;
}
await this._setFavorites(
this._favoritePositions.filter((_, itemIndex) => itemIndex !== index)
);
}
private _renderFavorite: HaMoreInfoFavorites["renderItem"] = (
favorite,
_index,
editMode
) => {
const value = favorite as number;
const active = this._currentValue() === value;
const label = this._localizeFavorite(editMode ? "edit" : "set", {
value: `${value}%`,
});
return html`
<ha-control-button
class=${classMap({
active,
})}
style=${styleMap({
"--control-button-border-radius": "var(--ha-border-radius-pill)",
width: "72px",
height: "36px",
})}
.label=${label}
.disabled=${this.stateObj.state === UNAVAILABLE}
>
${value}%
</ha-control-button>
`;
};
private _deleteLabel = (index: number): string =>
this._localizeFavorite("delete", {
number: index + 1,
});
private _handleFavoriteAction = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-action"]>
): void => {
ev.stopPropagation();
const { action, index } = ev.detail;
if (action === "hold" && this.hass.user?.is_admin) {
fireEvent(this, "toggle-edit-mode", true);
return;
}
if (this.editMode) {
this._editFavorite(index);
return;
}
this._applyFavorite(index);
};
private _handleFavoriteMoved = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-moved"]>
): void => {
ev.stopPropagation();
this._move(ev.detail.oldIndex, ev.detail.newIndex);
};
private _handleFavoriteDelete = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-delete"]>
): void => {
ev.stopPropagation();
this._deleteFavorite(ev.detail.index);
};
private _handleFavoriteAdd = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-add"]>
): void => {
ev.stopPropagation();
this._addFavorite();
};
private _handleFavoriteDone = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-done"]>
): void => {
ev.stopPropagation();
fireEvent(this, "toggle-edit-mode", false);
};
private _renderSection(): TemplateResult | typeof nothing {
if (!this.editMode && this._favoritePositions.length === 0) {
return nothing;
}
return html`
<section class="group">
<ha-more-info-favorites
.items=${this._favoritePositions}
.renderItem=${this._renderFavorite}
.deleteLabel=${this._deleteLabel}
.editMode=${this.editMode ?? false}
.disabled=${this.stateObj.state === UNAVAILABLE}
.isAdmin=${Boolean(this.hass.user?.is_admin)}
.showDone=${true}
.addLabel=${this._localizeFavorite("add")}
.doneLabel=${this.hass.localize(
"ui.dialogs.more_info_control.exit_edit_mode"
)}
@favorite-item-action=${this._handleFavoriteAction}
@favorite-item-moved=${this._handleFavoriteMoved}
@favorite-item-delete=${this._handleFavoriteDelete}
@favorite-item-add=${this._handleFavoriteAdd}
@favorite-item-done=${this._handleFavoriteDone}
></ha-more-info-favorites>
</section>
`;
}
protected render(): TemplateResult | typeof nothing {
if (!this.stateObj || !this.entry) {
return nothing;
}
return html` <div class="groups">${this._renderSection()}</div> `;
}
static styles = css`
:host {
display: block;
width: 100%;
}
.groups {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-3);
}
.group {
width: 100%;
max-width: 384px;
margin: 0;
}
.group ha-more-info-favorites {
--favorite-items-max-width: 384px;
--favorite-item-active-background-color: var(--state-valve-active-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-valve-favorite-positions": HaMoreInfoValveFavoritePositions;
}
}

View File

@@ -7,6 +7,22 @@ import { CONTINUOUS_DOMAINS } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
import { isNumericEntity } from "../../data/history";
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
export const DOMAINS_NO_INFO = ["camera", "configurator"];
/**
* Entity domains that should be editable *if* they have an id present;

View File

@@ -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>
`;
}

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