Compare commits
564 Commits
20260429.4
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 22786df070 | |||
| e5c849359b | |||
| 4bfa4f2816 | |||
| afd86975d6 | |||
| 7b1eff9eef | |||
| 4f0c228756 | |||
| c86101ac6e | |||
| 29fa351b16 | |||
| 7c67633146 | |||
| 180e23ad9b | |||
| 9e7ddb3e5e | |||
| 4a0e46dc2c | |||
| 6af0040e73 | |||
| ba58ef6dc2 | |||
| fafbd7a674 | |||
| 07290a5d7e | |||
| 06141043a7 | |||
| 03e4f968b4 | |||
| 17d4f67f69 | |||
| 133a9171bc | |||
| c2adc2b84a | |||
| 2e3cbf6aab | |||
| 82b2a60f32 | |||
| 2eb1811524 | |||
| 04b284159a | |||
| ddce581fdb | |||
| 668a7df5cd | |||
| d7cad1becd | |||
| 1e412ad035 | |||
| 11611cd597 | |||
| 0d545d744b | |||
| f39dab2de5 | |||
| 1527117015 | |||
| 26794560ac | |||
| 976f9de8ad | |||
| 6810bc5412 | |||
| a4ca54b80b | |||
| 07f0ef0ded | |||
| cf89bb32ab | |||
| ec5cbd16d8 | |||
| 926abd7fc5 | |||
| e227bbe9a2 | |||
| f82b0b61e5 | |||
| a62c89ee00 | |||
| 4b6d07134c | |||
| 35829c301e | |||
| a73f587591 | |||
| 2e5f776af7 | |||
| e91cffe27c | |||
| adbca5145c | |||
| 59d5ded6a5 | |||
| daba5dd8be | |||
| 1e3e43ba46 | |||
| e4cc1eaad2 | |||
| 9aa687577f | |||
| 2556707370 | |||
| 854c57c0e0 | |||
| 055076c45e | |||
| d4ec72006d | |||
| 393d6a8a0a | |||
| 4a030884f5 | |||
| f65596cad8 | |||
| 1449c17fd1 | |||
| ce0e6a7665 | |||
| 460dace974 | |||
| 7111d8a8a8 | |||
| b96d1f2809 | |||
| 26bdff9a16 | |||
| 16ac66c1f8 | |||
| 8533dd586b | |||
| 2cfb947c9b | |||
| 466cf2dfb2 | |||
| 193bcad917 | |||
| 52d32aec42 | |||
| 9adb7215ce | |||
| 273967fe70 | |||
| 382e07379b | |||
| 01a8b8d3ef | |||
| 3bbce5607e | |||
| 7ce052e2a8 | |||
| e929558a9a | |||
| 9cd4a6937f | |||
| af617695b8 | |||
| 740ad9eb6b | |||
| caeedc41e3 | |||
| fbb76a8ba0 | |||
| 3340637ff3 | |||
| 534bea231c | |||
| 8635951394 | |||
| c46f286cb8 | |||
| cc6b51d53f | |||
| 6915ca8fdd | |||
| 677e53f685 | |||
| 46b6ae8d7b | |||
| 09fda1ca1e | |||
| 7c1522b975 | |||
| d26ad7b354 | |||
| 66235a4c99 | |||
| 6c02864334 | |||
| 3471cd103a | |||
| 9ae25d96f2 | |||
| 02361f2517 | |||
| 38055b9244 | |||
| d064127f18 | |||
| cb2d8db91b | |||
| 861d7757cc | |||
| 1331ec9e2d | |||
| 0f81311c76 | |||
| 8a85d1cf31 | |||
| 9ba34bdf9a | |||
| f0f28789de | |||
| f007ea9da1 | |||
| 8c51adf77f | |||
| 876c4d3e2e | |||
| cff72770e6 | |||
| ff49fa78f8 | |||
| 914b90ffae | |||
| 1f1d520fdf | |||
| 1cca5f3108 | |||
| 0859202043 | |||
| 125629ed39 | |||
| bbfa71e974 | |||
| 3e8c528863 | |||
| ffc4731205 | |||
| 3b699da86c | |||
| b316ef5f45 | |||
| 2c3e61b126 | |||
| 3bd1d45fe1 | |||
| ae66cfc12e | |||
| de45578c4c | |||
| 1221e74776 | |||
| 04ae6eb3b4 | |||
| 089849d283 | |||
| 70a20d8bcc | |||
| 6c448be3f1 | |||
| c6e5ae21e2 | |||
| b8ac4f3f3e | |||
| fab4022dee | |||
| d70a42930b | |||
| b91a087ab1 | |||
| d45b5d20a2 | |||
| ad593a6733 | |||
| 18c084b4da | |||
| d761a68bee | |||
| 937abc7e86 | |||
| 6d3979be17 | |||
| ef44a2d682 | |||
| 378c5a3c9d | |||
| aaea886d51 | |||
| a040dca639 | |||
| a59f33d54c | |||
| 4326e1880b | |||
| d8bd82d3bb | |||
| ca9879cb4e | |||
| 89bd78463b | |||
| 6350417c52 | |||
| 7f6f29629f | |||
| b224bd7077 | |||
| 6338d0ea4e | |||
| c21ffeacad | |||
| 69b33ff015 | |||
| 6e2968671b | |||
| 453d412549 | |||
| 4a3eea5d2b | |||
| ee755ff58a | |||
| 635a6442b4 | |||
| 3608156e83 | |||
| 8529980bdd | |||
| 9ff508259f | |||
| 1ec4dc6c79 | |||
| 39c88e573d | |||
| 99eb752a68 | |||
| 9cc63c1f53 | |||
| d2188f600f | |||
| d69d4c592d | |||
| a97df0409c | |||
| 468756bd2f | |||
| cc43caa87b | |||
| 1fb3efadfa | |||
| 69599352a3 | |||
| 5c0f2feac1 | |||
| 033d035b18 | |||
| 04a986cd2c | |||
| 5b09b1475d | |||
| b1b9ac23cf | |||
| aa19ca91a3 | |||
| 1388aa56ea | |||
| 6ca7ac1ca5 | |||
| d4380248c2 | |||
| 14617aaf3c | |||
| 42e1051d9c | |||
| cfe30114f0 | |||
| 288c03c248 | |||
| 8cd9a5adf6 | |||
| 4d3437b491 | |||
| ceb51714be | |||
| d2c868f904 | |||
| 9f34de5de6 | |||
| 6c9452aa5a | |||
| 2cf79853aa | |||
| 6152812138 | |||
| 5540a6c1ff | |||
| e04297f2bd | |||
| e89f76bbbb | |||
| 319ba3940e | |||
| b9920065a2 | |||
| 3bb5201d41 | |||
| a0648b85ff | |||
| 54f901c7c9 | |||
| 2483a917f8 | |||
| d9cae08f53 | |||
| 106b35d6cf | |||
| f12d305688 | |||
| d2326b4f62 | |||
| ea9424053a | |||
| 70ffef8807 | |||
| a32169f300 | |||
| b508760d24 | |||
| 541cab83de | |||
| 8e8f2bfa4c | |||
| bafe21ab48 | |||
| ee56d7d003 | |||
| 486b6bb561 | |||
| 9a9ceaebf2 | |||
| ff5bbf46ae | |||
| 47fb4a2def | |||
| 0e716e5078 | |||
| 9e9fdfbad6 | |||
| 5ebdb99ba7 | |||
| 1ca454cf02 | |||
| 859d23c187 | |||
| f9d205defe | |||
| 00cc4e2a5a | |||
| 6571feb556 | |||
| 4150bc0806 | |||
| 958e3f2575 | |||
| 3d3292e2ad | |||
| 75b9fb2e34 | |||
| 38f0ce306b | |||
| 1ffd19e20b | |||
| 9a216cae46 | |||
| 41e6408508 | |||
| 97e85bc06f | |||
| 5f2ad7fa01 | |||
| 7b6b70023b | |||
| 256a06e35f | |||
| 4e26c05ac6 | |||
| 04ee8ac415 | |||
| 63e144309c | |||
| 77039cda8e | |||
| ab5b4ed792 | |||
| a08905cd31 | |||
| a35349196f | |||
| dbdfdedd74 | |||
| a5c8547b2b | |||
| e373689a37 | |||
| 5edcdb8977 | |||
| 26b8921e8c | |||
| b8c201b6d3 | |||
| 4a6c23c93e | |||
| e2712cb0b0 | |||
| db52cd0d8e | |||
| 4891783c86 | |||
| b73732acdb | |||
| d950514104 | |||
| f37cf1e848 | |||
| a188ef1b7a | |||
| 087ef159df | |||
| e39e1b3f5b | |||
| ff583d2274 | |||
| d4de29e073 | |||
| 97dfed0cc4 | |||
| 8b3df752da | |||
| 8c0d547962 | |||
| 5e3d84f0ad | |||
| b4e30bdf63 | |||
| 4fcae4231c | |||
| 2aecf33955 | |||
| 5f26a2b3da | |||
| b08f5bcb34 | |||
| c329e5b827 | |||
| 97f591337d | |||
| e6e6e75f73 | |||
| ff334de0ca | |||
| 8dbe97b480 | |||
| 7bea54851d | |||
| 7171575f8c | |||
| f4143c2070 | |||
| bbe6b88533 | |||
| 3a0c85cd3e | |||
| d22e2b8dd5 | |||
| 45e7d86bf8 | |||
| d1bf5fe33c | |||
| fb0a54231a | |||
| a147fc4fee | |||
| a300085208 | |||
| 44989a6972 | |||
| 54a8e6c294 | |||
| bfec22d828 | |||
| cde6450cfc | |||
| ab39e70629 | |||
| 69f209e3c3 | |||
| f4c5561a54 | |||
| 5147937a6f | |||
| ee39605aa7 | |||
| 4af4f1dc51 | |||
| a2d8859d94 | |||
| afea8180c4 | |||
| b9c077489d | |||
| 440bb32056 | |||
| 8f371621ad | |||
| 61815b20e3 | |||
| 1942fa3a77 | |||
| 865e67a06f | |||
| 412dce4c1f | |||
| ced2ac7ad5 | |||
| 6649f52bcd | |||
| 7dbd6ae5a2 | |||
| e1528d21b3 | |||
| 79cb3137f2 | |||
| 313360701a | |||
| b100d9577d | |||
| 44ce303302 | |||
| 8f76613068 | |||
| 85dff6640a | |||
| ab7c892b6b | |||
| 3fe57ad724 | |||
| 1caf1d99b5 | |||
| 483df2fa2f | |||
| e0adb006b6 | |||
| 50e34015b3 | |||
| c1c926c631 | |||
| c41afac57c | |||
| 8856c26929 | |||
| 4a0fe3190c | |||
| 08f7e97462 | |||
| a5791c8c08 | |||
| 6a98a74c58 | |||
| c1df3bc38e | |||
| 58d4edaa63 | |||
| 176841e647 | |||
| 0759e82b47 | |||
| 7c6609aee7 | |||
| 7048c5f3d2 | |||
| 9ed47be6c3 | |||
| 128f4526e3 | |||
| 3f1b7ce391 | |||
| 4073b4e1f5 | |||
| 86a24d1532 | |||
| 46bab5bb01 | |||
| e8f486af0a | |||
| 211579eade | |||
| f6458925c9 | |||
| ae5e35e7ed | |||
| 8c1727859a | |||
| 287562221f | |||
| 2593dfed8d | |||
| 2d92f1fb3b | |||
| 8cff4c6bd2 | |||
| 5aa8455861 | |||
| 4d142734d8 | |||
| eaecc76f36 | |||
| 7dc0033c03 | |||
| 601e6d0542 | |||
| c7ca3dd837 | |||
| f75a376add | |||
| a541204ffb | |||
| cbbce90eae | |||
| 950de204aa | |||
| 91b6a4c4b6 | |||
| 643cc4ca7d | |||
| 9ef71e6cf4 | |||
| bface72af7 | |||
| 90028b2e22 | |||
| 914c48abd5 | |||
| 79c082acde | |||
| 4728eb7231 | |||
| d02b92bd32 | |||
| 98525d23e6 | |||
| ec98b21276 | |||
| defad3beca | |||
| 635d61256b | |||
| 60c5bea6e0 | |||
| aed83ccc07 | |||
| 85b2ca377a | |||
| e194247f50 | |||
| c79956b893 | |||
| abb4cbc263 | |||
| 811397f740 | |||
| c7c78bd587 | |||
| bf67a3ec1d | |||
| b0772d6701 | |||
| d484ef3f2f | |||
| 6f8fffccbd | |||
| df03a0dfd9 | |||
| bb12cb19b5 | |||
| 7baf7f4701 | |||
| 0dfb801ff6 | |||
| d94dcf50fb | |||
| fb1f5ef722 | |||
| e5d5797d91 | |||
| adee24f745 | |||
| 1b695e24d0 | |||
| 7f9259edf9 | |||
| 6954dc1a54 | |||
| 032d0fb332 | |||
| 43ed97da43 | |||
| 9f4d35bc05 | |||
| 11afde6b5f | |||
| 1b0dcb33b1 | |||
| 67eecbc51d | |||
| 9298e00f20 | |||
| 969ccf85d2 | |||
| 500ce18ae5 | |||
| b413a7742c | |||
| e84373fdbd | |||
| caaee14856 | |||
| 28f04df81d | |||
| 48a8c5b2d5 | |||
| 45312ba7fd | |||
| b5dad80e19 | |||
| ae85263d91 | |||
| c5000bcdde | |||
| 5e085c70b0 | |||
| 71fc44284c | |||
| b7e1e23eaa | |||
| 2ee7c6fc2a | |||
| 7d069c4f5e | |||
| 20bf8181dd | |||
| 1884a06f98 | |||
| 0c63078923 | |||
| c6ae47f1c8 | |||
| 0a9fe0e0c7 | |||
| c3480bc319 | |||
| 8af5908682 | |||
| 60e95b886c | |||
| 0385ca8076 | |||
| 02c65fc8cb | |||
| 49290d5c83 | |||
| 08aff3bfd7 | |||
| 455fa45b9c | |||
| 2e56a4ec4c | |||
| 76131ff09e | |||
| 89d8723c5a | |||
| 7bdb63a6fe | |||
| eed79f1797 | |||
| 76665009da | |||
| 6d7d08fddc | |||
| 77d4e6dc43 | |||
| 7345256b30 | |||
| e0d98e95fa | |||
| 17041044cf | |||
| 9a10cd7fa8 | |||
| fa354aed2a | |||
| c044d96712 | |||
| 1b736960b2 | |||
| e8c8047ff9 | |||
| 9376f4ce81 | |||
| 7befa9782a | |||
| 0186ec1265 | |||
| 641c444fdc | |||
| 93dd2a5dc8 | |||
| f7cf3d5b39 | |||
| b861543865 | |||
| e749956eaa | |||
| 5b0f0dade5 | |||
| f86d2753f7 | |||
| f3f549737f | |||
| d9929905b5 | |||
| 25487e373e | |||
| 2ff56d3eb7 | |||
| 6c4f7506b5 | |||
| 5755aebff6 | |||
| 76996ea3cc | |||
| d7d6766f80 | |||
| b632e8e6f8 | |||
| ee4eaaa613 | |||
| 395faebd0c | |||
| 71b8676e02 | |||
| d54516dd42 | |||
| 1a3eef9c4f | |||
| 1f2f9e6330 | |||
| 1774219f9a | |||
| ac66ad1a32 | |||
| 7bb51c746d | |||
| 13e32c41e0 | |||
| d89af52e3b | |||
| da6114fa5f | |||
| c144533834 | |||
| e6c6ab93ef | |||
| 62df56e5d9 | |||
| d169eb9c49 | |||
| 0e1aa400d7 | |||
| 00e57454ed | |||
| 0e6b342b3f | |||
| 7ad8c27aa3 | |||
| f01c202bbd | |||
| ac6439bb5b | |||
| 33d29e3abd | |||
| ca4ff25073 | |||
| a4b4e285d8 | |||
| 850b597e47 | |||
| b2e07c3ba5 | |||
| 76c871b249 | |||
| c15d514918 | |||
| 8a52fa5f7a | |||
| 22c89ceff9 | |||
| 764f99beb3 | |||
| 64b242e89c | |||
| 8620653a54 | |||
| 103861bf71 | |||
| b0a885f504 | |||
| d620919643 | |||
| f190a4f75c | |||
| 9c0f4ef8eb | |||
| f25692a6f3 | |||
| 8b0d193742 | |||
| da8dedbdea | |||
| 405ea0d09d | |||
| afce0703e3 | |||
| be0abafdff | |||
| 4aa9b188a0 | |||
| 1312cdceda | |||
| 7dddcc0feb | |||
| 95edd6c2c2 | |||
| 38a18e327c | |||
| a288ad4ab6 | |||
| 89a85d6f04 | |||
| 6f1d644676 | |||
| 3edf8beb5a | |||
| 7b95baf36b | |||
| b9c9008135 | |||
| a8fb2e251e | |||
| 5c93e7adbc | |||
| 4745cb4103 | |||
| 0a27727b9f | |||
| 2644706d5a | |||
| dd25b448cf | |||
| 884c110bcc | |||
| c61ed9c56a | |||
| b454a45ca3 | |||
| 3bc404bc01 | |||
| f22fc0b68a | |||
| c78cfb4012 | |||
| 09e993ffd6 | |||
| f8f175426d | |||
| 89e3687f22 | |||
| 18a20576a9 | |||
| 8ee41e5d9b | |||
| cac31ac55a | |||
| 3046f3e47d | |||
| 8f002f2783 | |||
| df754fcd0d | |||
| bc4437b3b5 | |||
| c99b43dcf3 | |||
| 8945b917b3 | |||
| 4d75ea5198 | |||
| ba3a63f856 | |||
| fd25d38be6 | |||
| ac22374a00 | |||
| de529cc26b | |||
| 126db3e8df | |||
| ed6fd59968 | |||
| 121ed7ac1f |
@@ -2,12 +2,13 @@
|
||||
|
||||
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
|
||||
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For gallery-specific documentation, demos, page structure, and usage examples, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Core Architecture](#core-architecture)
|
||||
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- [Development Standards](#development-standards)
|
||||
- [Component Library](#component-library)
|
||||
- [Common Patterns](#common-patterns)
|
||||
@@ -40,7 +41,7 @@ script/develop # Development server
|
||||
```typescript
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
```
|
||||
|
||||
## Core Architecture
|
||||
@@ -52,13 +53,64 @@ The Home Assistant frontend is a modern web application that:
|
||||
- Communicates with the backend via WebSocket API
|
||||
- Provides comprehensive theming and internationalization
|
||||
|
||||
## State Access: Contexts Instead of `hass`
|
||||
|
||||
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
|
||||
|
||||
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
|
||||
|
||||
### Contexts
|
||||
|
||||
Consume the narrowest context that covers your reads:
|
||||
|
||||
| Context | Replaces |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
|
||||
|
||||
### Consuming
|
||||
|
||||
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For any other single field, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Code Quality Requirements
|
||||
|
||||
**Linting and Formatting (Enforced by Tools)**
|
||||
|
||||
- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility
|
||||
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
|
||||
- Prettier with ES5 trailing commas enforced
|
||||
- No console statements (`no-console: "error"`) - use proper logging
|
||||
- Import organization: No unused imports, consistent type imports
|
||||
@@ -136,6 +188,7 @@ export class HaMyComponent extends LitElement {
|
||||
### Data Management
|
||||
|
||||
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
|
||||
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- **Cache appropriately**: Use collections and caching for frequently accessed data
|
||||
- **Handle errors gracefully**: All API calls should have error handling
|
||||
- **Update real-time**: Subscribe to state changes for live updates
|
||||
@@ -160,7 +213,7 @@ try {
|
||||
- Defined in `src/resources/theme/core.globals.ts`
|
||||
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
|
||||
- **Mobile-first responsive**: Design for mobile, enhance for desktop
|
||||
- **Follow Material Design**: Use Material Web Components where appropriate
|
||||
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
|
||||
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||
|
||||
```typescript
|
||||
@@ -236,6 +289,7 @@ For browser support, API details, and current specifications, refer to these aut
|
||||
- **Test with Vitest**: Use the established test framework
|
||||
- **Mock appropriately**: Mock WebSocket connections and API calls
|
||||
- **Test accessibility**: Ensure components are accessible
|
||||
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
|
||||
|
||||
## Component Library
|
||||
|
||||
@@ -267,22 +321,24 @@ fireEvent(this, "show-dialog", {
|
||||
|
||||
**Dialog Sizing:**
|
||||
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
|
||||
- Custom sizing is NOT recommended - use the standard width presets
|
||||
|
||||
**Button Appearance Guidelines:**
|
||||
|
||||
- **Primary action buttons**: Default appearance (no appearance attribute) or omit for standard styling
|
||||
- **Secondary action buttons**: Use `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: Use `appearance="filled"` for delete/remove operations (combined with appropriate semantic styling)
|
||||
- **Button sizes**: Use `size="small"` (32px height) or default/medium (40px height)
|
||||
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
|
||||
|
||||
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
|
||||
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
|
||||
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
|
||||
|
||||
Common patterns:
|
||||
|
||||
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
|
||||
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
|
||||
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-dialog.markdown`
|
||||
- `gallery/src/pages/components/ha-dialogs.markdown`
|
||||
|
||||
### Form Component (ha-form)
|
||||
|
||||
- Schema-driven using `HaFormSchema[]`
|
||||
@@ -301,14 +357,11 @@ fireEvent(this, "show-dialog", {
|
||||
></ha-form>
|
||||
```
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-form.markdown`
|
||||
|
||||
### Alert Component (ha-alert)
|
||||
|
||||
- Types: `error`, `warning`, `info`, `success`
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
|
||||
- Slots: `icon` (override the leading icon), `action` (custom action content)
|
||||
- Content announced by screen readers when dynamically displayed
|
||||
|
||||
```html
|
||||
@@ -317,10 +370,6 @@ fireEvent(this, "show-dialog", {
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-alert.markdown`
|
||||
|
||||
### Keyboard Shortcuts (ShortcutManager)
|
||||
|
||||
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
|
||||
@@ -344,7 +393,6 @@ The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming
|
||||
|
||||
- **Component definition**: `src/components/ha-tooltip.ts`
|
||||
- **Usage example**: `src/components/ha-label.ts`
|
||||
- **Gallery documentation**: `gallery/src/pages/components/ha-tooltip.markdown`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
@@ -374,7 +422,7 @@ export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
|
||||
|
||||
#### Creating a Lovelace Card
|
||||
|
||||
**Purpose**: Cards allow users to tell different stories about their house (based on gallery)
|
||||
**Purpose**: Cards allow users to tell different stories about their house.
|
||||
|
||||
```typescript
|
||||
@customElement("hui-my-card")
|
||||
@@ -447,9 +495,13 @@ this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
4. **Test**: `yarn test` - Add and run tests
|
||||
5. **Build**: `script/build_frontend` - Test production build
|
||||
|
||||
### Gallery
|
||||
|
||||
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
- Don't use `querySelector` - Use refs or component properties
|
||||
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
|
||||
- Don't manipulate DOM directly - Let Lit handle rendering
|
||||
- Don't use global styles - Scope styles to components
|
||||
- Don't block the main thread - Use web workers for heavy computation
|
||||
@@ -477,7 +529,7 @@ When creating a pull request, you **must** use the PR template located at `.gith
|
||||
|
||||
#### Terminology Standards
|
||||
|
||||
**Delete vs Remove** (Based on gallery/src/pages/Text/remove-delete-add-create.markdown)
|
||||
**Delete vs Remove**
|
||||
|
||||
- **Use "Remove"** for actions that can be restored or reapplied:
|
||||
- Removing a user's permission
|
||||
@@ -540,35 +592,24 @@ When creating a pull request, you **must** use the PR template located at `.gith
|
||||
|
||||
#### Translation Considerations
|
||||
|
||||
- **Add translation keys**: All user-facing text must be translatable
|
||||
- **Use placeholders**: Support dynamic content in translations
|
||||
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
|
||||
|
||||
- **Keep context**: Provide enough context for translators
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
this.hass.localize("ui.panel.config.automation.delete_confirm", {
|
||||
name: automation.alias,
|
||||
});
|
||||
|
||||
// Bad - hardcoded text
|
||||
("Are you sure you want to delete this automation?");
|
||||
```
|
||||
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
|
||||
|
||||
### Common Review Issues (From PR Analysis)
|
||||
|
||||
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
|
||||
|
||||
#### User Experience and Accessibility
|
||||
|
||||
- **Form validation**: Always provide proper field labels and validation feedback
|
||||
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
|
||||
- **Loading states**: Show clear progress indicators during async operations
|
||||
- **Error handling**: Display meaningful error messages when operations fail
|
||||
- **Mobile responsiveness**: Ensure components work well on small screens
|
||||
- **Hit targets**: Make clickable areas large enough for touch interaction
|
||||
- **Visual feedback**: Provide clear indication of interactive states
|
||||
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
|
||||
|
||||
#### Dialog and Modal Patterns
|
||||
|
||||
- **Dialog width constraints**: Respect minimum and maximum width requirements
|
||||
- **Interview progress**: Show clear progress for multi-step operations
|
||||
- **State persistence**: Handle dialog state properly during background operations
|
||||
- **Cancel behavior**: Ensure cancel/close buttons work consistently
|
||||
@@ -580,15 +621,12 @@ this.hass.localize("ui.panel.config.automation.delete_confirm", {
|
||||
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
|
||||
- **Grid alignment**: Components should align to the design grid system
|
||||
- **Badge placement**: Position badges and indicators consistently
|
||||
- **Color theming**: Respect theme variables and design system colors
|
||||
|
||||
#### Code Quality Issues
|
||||
|
||||
- **Null checking**: Always check if entities exist before accessing properties
|
||||
- **TypeScript safety**: Handle potentially undefined array/object access
|
||||
- **Import organization**: Remove unused imports and use proper type imports
|
||||
- **Event handling**: Properly subscribe and unsubscribe from events
|
||||
- **Memory leaks**: Clean up subscriptions and event listeners
|
||||
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
|
||||
|
||||
#### Configuration and Props
|
||||
|
||||
@@ -599,39 +637,12 @@ this.hass.localize("ui.panel.config.automation.delete_confirm", {
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
### Core Requirements Checklist
|
||||
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
|
||||
|
||||
- [ ] TypeScript strict mode passes (`yarn lint:types`)
|
||||
- [ ] No ESLint errors or warnings (`yarn lint:eslint`)
|
||||
- [ ] Prettier formatting applied (`yarn lint:prettier`)
|
||||
- [ ] Lit analyzer passes (`yarn lint:lit`)
|
||||
- [ ] Component follows Lit best practices
|
||||
- [ ] Proper error handling implemented
|
||||
- [ ] Loading states handled
|
||||
- [ ] Mobile responsive
|
||||
- [ ] Theme variables used
|
||||
- [ ] Translations added
|
||||
- [ ] Accessible to screen readers
|
||||
- [ ] Tests added (where applicable)
|
||||
- [ ] No console statements (use proper logging)
|
||||
- [ ] Unused imports removed
|
||||
- [ ] Proper naming conventions
|
||||
|
||||
### Text and Copy Checklist
|
||||
|
||||
- [ ] Follows terminology guidelines (Delete vs Remove, Create vs Add)
|
||||
- [ ] Localization keys added for all user-facing text
|
||||
- [ ] Uses "Home Assistant" (never "HA" or "HASS")
|
||||
- [ ] Sentence case for ALL text (titles, headings, buttons, labels)
|
||||
- [ ] American English spelling
|
||||
- [ ] Friendly, informational tone
|
||||
- [ ] Avoids abbreviations and jargon
|
||||
- [ ] Correct terminology (integration not component)
|
||||
|
||||
### Component-Specific Checks
|
||||
|
||||
- [ ] ha-alert used correctly for messages
|
||||
- [ ] ha-form uses proper schema structure
|
||||
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
|
||||
- [ ] Tests added for new data processing/utilities (where applicable)
|
||||
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
|
||||
- [ ] Components handle all states (loading, error, unavailable)
|
||||
- [ ] Entity existence checked before property access
|
||||
- [ ] Event subscriptions properly cleaned up
|
||||
- [ ] Event/subscription listeners cleaned up (no memory leaks)
|
||||
- [ ] Accessible to screen readers and keyboard
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Blocking labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map(
|
||||
(l) => l.name
|
||||
);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
|
||||
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
|
||||
.write();
|
||||
}
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --alias dev
|
||||
npx -y netlify-cli deploy --dir=cast/dist --alias dev
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --prod
|
||||
npx -y netlify-cli deploy --dir=cast/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -58,12 +58,14 @@ jobs:
|
||||
run: yarn run lint:lit --quiet
|
||||
- name: Run prettier
|
||||
run: yarn run lint:prettier
|
||||
- name: Check dependency licenses
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -83,7 +85,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --prod
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Deploy preview to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
--json > deploy_output.json
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -13,13 +13,11 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
process-only: "issues, prs"
|
||||
issue-lock-inactive-days: "30"
|
||||
issue-exclude-created-before: "2020-10-01T00:00:00Z"
|
||||
issue-inactive-days: "30"
|
||||
issue-lock-reason: ""
|
||||
pr-lock-inactive-days: "1"
|
||||
pr-exclude-created-before: "2020-11-01T00:00:00Z"
|
||||
pr-inactive-days: "1"
|
||||
pr-lock-reason: ""
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
token: ${{ github.token }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
|
||||
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -77,14 +77,27 @@ jobs:
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
# Sleep to give pypi time to populate the new version across mirrors
|
||||
sleep 240
|
||||
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
|
||||
# Wait for the package to become available on PyPI
|
||||
echo "Waiting for home-assistant-frontend==$version to appear on PyPI..."
|
||||
for i in $(seq 1 30); do
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/home-assistant-frontend/$version/json")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Package is available on PyPI!"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
echo "Timed out waiting for package to appear on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
echo "Not available yet (HTTP $status), retrying in 30 seconds... ($i/30)"
|
||||
sleep 30
|
||||
done
|
||||
echo "home-assistant-frontend==$version" > ./requirements.txt
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
@@ -100,7 +113,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -58,3 +58,4 @@ test/coverage/
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
test/benchmarks/results/
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
diff --git a/dist/tinykeys.cjs b/dist/tinykeys.cjs
|
||||
index 08c98b6eff3b8fb4b727fe8e6b096951d6ef6347..9c44f14862f582766ea1733b6dc0e97f962800d8 100644
|
||||
--- a/dist/tinykeys.cjs
|
||||
+++ b/dist/tinykeys.cjs
|
||||
@@ -61,6 +61,18 @@ function defaultKeybindingsHandlerIgnore(event) {
|
||||
function getModifierState(event, mod) {
|
||||
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
|
||||
}
|
||||
+function splitKeybindingPress(press) {
|
||||
+ let parts = [];
|
||||
+ let start = 0;
|
||||
+ for (let index = 0; index < press.length; index++) {
|
||||
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
|
||||
+ parts.push(press.slice(start, index));
|
||||
+ start = index + 1;
|
||||
+ }
|
||||
+ }
|
||||
+ parts.push(press.slice(start));
|
||||
+ return parts;
|
||||
+}
|
||||
/**
|
||||
* Parses a keybinding string into its parts.
|
||||
*
|
||||
@@ -76,10 +88,10 @@ function getModifierState(event, mod) {
|
||||
*/
|
||||
function parseKeybinding(str) {
|
||||
return str.trim().split(" ").map((press) => {
|
||||
- let parts = press.split(/(?<=\w|\])\+/);
|
||||
+ let parts = splitKeybindingPress(press);
|
||||
let last = parts.pop();
|
||||
let regex = last.match(/^\((.+)\)$/);
|
||||
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
|
||||
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
|
||||
let requiredModifiers = [];
|
||||
let optionalModifiers = [];
|
||||
for (const part of parts) {
|
||||
@@ -201,5 +213,3 @@ exports.defaultKeybindingsHandlerIgnore = defaultKeybindingsHandlerIgnore;
|
||||
exports.matchKeybindingPress = matchKeybindingPress;
|
||||
exports.parseKeybinding = parseKeybinding;
|
||||
exports.tinykeys = tinykeys;
|
||||
-
|
||||
-//# sourceMappingURL=tinykeys.cjs.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/tinykeys.mjs b/dist/tinykeys.mjs
|
||||
index c289972d2728e03d9b272268c38fd3392e8845bf..e22897b00aae6cdb0dbbb971445227c07be52918 100644
|
||||
--- a/dist/tinykeys.mjs
|
||||
+++ b/dist/tinykeys.mjs
|
||||
@@ -60,6 +60,18 @@ function defaultKeybindingsHandlerIgnore(event) {
|
||||
function getModifierState(event, mod) {
|
||||
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
|
||||
}
|
||||
+function splitKeybindingPress(press) {
|
||||
+ let parts = [];
|
||||
+ let start = 0;
|
||||
+ for (let index = 0; index < press.length; index++) {
|
||||
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
|
||||
+ parts.push(press.slice(start, index));
|
||||
+ start = index + 1;
|
||||
+ }
|
||||
+ }
|
||||
+ parts.push(press.slice(start));
|
||||
+ return parts;
|
||||
+}
|
||||
/**
|
||||
* Parses a keybinding string into its parts.
|
||||
*
|
||||
@@ -75,10 +87,10 @@ function getModifierState(event, mod) {
|
||||
*/
|
||||
function parseKeybinding(str) {
|
||||
return str.trim().split(" ").map((press) => {
|
||||
- let parts = press.split(/(?<=\w|\])\+/);
|
||||
+ let parts = splitKeybindingPress(press);
|
||||
let last = parts.pop();
|
||||
let regex = last.match(/^\((.+)\)$/);
|
||||
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
|
||||
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
|
||||
let requiredModifiers = [];
|
||||
let optionalModifiers = [];
|
||||
for (const part of parts) {
|
||||
@@ -196,5 +208,3 @@ function tinykeys(target, keybindingMap, options = {}) {
|
||||
}
|
||||
//#endregion
|
||||
export { createKeybindingsHandler, defaultKeybindingsHandlerIgnore, matchKeybindingPress, parseKeybinding, tinykeys };
|
||||
-
|
||||
-//# sourceMappingURL=tinykeys.mjs.map
|
||||
\ No newline at end of file
|
||||
@@ -1,11 +1,16 @@
|
||||
compressionLevel: mixed
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
npmMinimalAgeGate: "3d"
|
||||
compressionLevel: mixed
|
||||
|
||||
defaultSemverRangePrefix: ""
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.14.1.cjs
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.16.0.cjs
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./licenses.js";
|
||||
import "./locale-data.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
@@ -36,7 +37,12 @@ gulp.task(
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"build-translations",
|
||||
"build-locale-data",
|
||||
"gen-licenses"
|
||||
),
|
||||
"copy-static-app",
|
||||
"rspack-prod-app",
|
||||
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
|
||||
|
||||
@@ -57,7 +57,9 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
if (descriptionContent === "") {
|
||||
hasDescription = false;
|
||||
} else {
|
||||
descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
|
||||
descriptionContent = marked(descriptionContent)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/`/g, "\\`");
|
||||
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.resolve(galleryBuild, `${pageId}-description.ts`),
|
||||
@@ -101,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
|
||||
if (!toProcess) {
|
||||
console.error("Unknown category", group.category);
|
||||
if (!group.pages) {
|
||||
if (!group.subsections && !group.pages) {
|
||||
group.pages = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.subsections) {
|
||||
// Listed pages keep their per-subsection order.
|
||||
for (const subsection of group.subsections) {
|
||||
for (const page of subsection.pages) {
|
||||
if (!toProcess.delete(page)) {
|
||||
console.error("Found unreferenced demo", page);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any remaining pages land in a trailing "Other" subsection.
|
||||
const leftover = Array.from(toProcess).sort();
|
||||
if (leftover.length) {
|
||||
group.subsections.push({ header: "Other", pages: leftover });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any pre-defined groups will not be sorted.
|
||||
if (group.pages) {
|
||||
for (const page of group.pages) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Gulp task to generate third-party license notices.
|
||||
|
||||
import { readFile, access } from "fs/promises";
|
||||
import { generateLicenseFile } from "generate-license-file";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const OUTPUT_FILE = path.join(
|
||||
paths.app_output_static,
|
||||
"third-party-licenses.txt"
|
||||
);
|
||||
|
||||
// The echarts package ships an Apache-2.0 NOTICE file that must be
|
||||
// redistributed alongside the compiled output per Apache License §4(d).
|
||||
const NOTICE_FILES = [
|
||||
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
|
||||
];
|
||||
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
//
|
||||
// Each entry is pinned to a specific version. If a package is updated,
|
||||
// this list must be reviewed and the version updated after verifying
|
||||
// that the new version's license still matches. The build will fail
|
||||
// if the installed version does not match the pinned version.
|
||||
const LICENSE_OVERRIDES = [
|
||||
{
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
packageName: "type-fest",
|
||||
version: "5.7.0",
|
||||
licensePath: path.resolve(
|
||||
paths.root_dir,
|
||||
"node_modules/type-fest/license-mit"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
gulp.task("gen-licenses", async () => {
|
||||
const licenseOverrides = {};
|
||||
|
||||
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
|
||||
const pkgJsonPath = path.resolve(
|
||||
paths.root_dir,
|
||||
`node_modules/${packageName}/package.json`
|
||||
);
|
||||
|
||||
let packageJSON;
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
|
||||
} catch {
|
||||
throw new Error(
|
||||
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
|
||||
);
|
||||
}
|
||||
|
||||
if (packageJSON.version !== version) {
|
||||
throw new Error(
|
||||
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
|
||||
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await access(licensePath);
|
||||
} catch {
|
||||
throw new Error(`License file not found or unreadable: ${licensePath}`);
|
||||
}
|
||||
|
||||
licenseOverrides[`${packageName}@${version}`] = licensePath;
|
||||
}
|
||||
|
||||
await generateLicenseFile(
|
||||
path.resolve(paths.root_dir, "package.json"),
|
||||
OUTPUT_FILE,
|
||||
{ append: NOTICE_FILES, replace: licenseOverrides }
|
||||
);
|
||||
});
|
||||
@@ -33,7 +33,9 @@ const isWsl =
|
||||
* compiler: import("@rspack/core").Compiler,
|
||||
* contentBase: string,
|
||||
* port: number,
|
||||
* listenHost?: string
|
||||
* listenHost?: string,
|
||||
* open?: boolean,
|
||||
* logUrlAfterFirstBuild?: boolean,
|
||||
* }}
|
||||
*/
|
||||
const runDevServer = async ({
|
||||
@@ -41,16 +43,31 @@ const runDevServer = async ({
|
||||
contentBase,
|
||||
port,
|
||||
listenHost = undefined,
|
||||
open = true,
|
||||
logUrlAfterFirstBuild = false,
|
||||
proxy = undefined,
|
||||
}) => {
|
||||
if (listenHost === undefined) {
|
||||
// For dev container, we need to listen on all hosts
|
||||
listenHost = env.isDevContainer() ? "0.0.0.0" : "localhost";
|
||||
}
|
||||
const url = `http://localhost:${port}`;
|
||||
let loggedUrl = false;
|
||||
if (logUrlAfterFirstBuild) {
|
||||
compiler.hooks.done.tap("log-dev-server-url", () => {
|
||||
if (loggedUrl) {
|
||||
return;
|
||||
}
|
||||
loggedUrl = true;
|
||||
setTimeout(() => {
|
||||
log("[rspack-dev-server]", `Project is running at ${url}`);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
const server = new RspackDevServer(
|
||||
{
|
||||
hot: false,
|
||||
open: true,
|
||||
open,
|
||||
host: listenHost,
|
||||
port,
|
||||
static: {
|
||||
@@ -70,7 +87,9 @@ const runDevServer = async ({
|
||||
|
||||
await server.start();
|
||||
// Server listening
|
||||
log("[rspack-dev-server]", `Project is running at http://localhost:${port}`);
|
||||
if (!logUrlAfterFirstBuild) {
|
||||
log("[rspack-dev-server]", `Project is running at ${url}`);
|
||||
}
|
||||
};
|
||||
|
||||
const doneHandler = (done) => (err, stats) => {
|
||||
@@ -172,6 +191,8 @@ gulp.task("rspack-dev-server-gallery", () =>
|
||||
contentBase: paths.gallery_output_root,
|
||||
port: 8100,
|
||||
listenHost: "0.0.0.0",
|
||||
open: false,
|
||||
logUrlAfterFirstBuild: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @ts-check
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import globals from "globals";
|
||||
import js from "@eslint/js";
|
||||
@@ -11,6 +13,10 @@ import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
|
||||
import html from "@html-eslint/eslint-plugin";
|
||||
import importX from "eslint-plugin-import-x";
|
||||
|
||||
const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
);
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
@@ -50,7 +56,7 @@ export default tseslint.config(
|
||||
settings: {
|
||||
"import-x/resolver": {
|
||||
webpack: {
|
||||
config: "./rspack.config.cjs",
|
||||
config: rspackConfigPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Gallery Agent Instructions
|
||||
|
||||
This file applies to all files under `gallery/`. Follow the root `AGENTS.md` for repository-wide Home Assistant frontend, TypeScript, Lit, accessibility, and copy standards. This file adds gallery-specific structure, page, demo, and verification guidance.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Run commands from the repository root unless noted otherwise:
|
||||
|
||||
```bash
|
||||
gallery/script/develop_gallery # Start the gallery development server
|
||||
gallery/script/build_gallery # Build the static gallery
|
||||
yarn lint # ESLint, Prettier, TypeScript, and Lit checks
|
||||
yarn lint:types # TypeScript compiler, without file arguments
|
||||
```
|
||||
|
||||
Never run `yarn lint:types` or `tsc` with file arguments. See the root `AGENTS.md` for the generated `.js` file risk.
|
||||
|
||||
## Purpose
|
||||
|
||||
The gallery is a developer and designer reference for Home Assistant frontend UI patterns. It documents component APIs, shows realistic Lovelace and more-info states, captures brand and copy guidance, and provides reproducible demos that are safe to inspect outside a running Home Assistant instance.
|
||||
|
||||
- Prefer demonstrating real production components from `src/` instead of creating gallery-only replacements.
|
||||
- Keep fake state, sample data, and demo-only helpers inside `gallery/`.
|
||||
- Do not move gallery stubs or demo data into production code unless a production feature explicitly needs them.
|
||||
- Do not hand-edit generated output under `gallery/build/` or `gallery/dist/`.
|
||||
|
||||
## Structure
|
||||
|
||||
- `sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
|
||||
- `script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
|
||||
- `script/build_gallery`: Wrapper for the `build-gallery` gulp task.
|
||||
- `src/entrypoint.js`: Creates the `<ha-gallery>` shell.
|
||||
- `src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
|
||||
- `src/html/index.html.template`: HTML template used by the gallery build.
|
||||
- `src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
|
||||
- `src/pages/<category>/<page>.ts`: Optional live demo module for the same page id.
|
||||
- `src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
|
||||
- `src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
|
||||
- `public/`: Static assets copied into the gallery output.
|
||||
|
||||
## Page Model
|
||||
|
||||
Gallery pages are generated by `gather-gallery-pages` in `build-scripts/gulp/gallery.js`.
|
||||
|
||||
- A page id is the path under `src/pages/` without the extension, like `components/ha-button`.
|
||||
- A `.markdown` file and a `.ts` file with the same page id become one gallery page.
|
||||
- A page may have only markdown, only a TypeScript demo, or both.
|
||||
- Markdown can contain YAML frontmatter with `title` and optional `subtitle`.
|
||||
- Markdown that contains only frontmatter contributes metadata without rendering a description block.
|
||||
- TypeScript demo modules are dynamically imported for side effects when the page is opened.
|
||||
- A demo module must define a custom element named `demo-${category}-${page}` with slashes replaced by hyphens, like `demo-components-ha-button` for `components/ha-button`.
|
||||
- `ha-gallery.ts` renders that element with `dynamicElement()` based on the current page id.
|
||||
|
||||
## Sidebar
|
||||
|
||||
Use `sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
|
||||
|
||||
- `category` must match the first directory name under `src/pages/`.
|
||||
- `header` is the section label shown in the drawer.
|
||||
- `pages` is optional. When present, listed pages keep that exact order.
|
||||
- Pages in a category that are not listed are appended alphabetically after the listed pages.
|
||||
- New categories without a sidebar entry are appended by the generator with their category name as the header.
|
||||
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
|
||||
|
||||
### Subsections
|
||||
|
||||
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
|
||||
|
||||
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
|
||||
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
|
||||
- Listed pages keep their per-subsection order.
|
||||
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
|
||||
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
|
||||
- Use sentence case for subsection headers and follow the content standards below.
|
||||
|
||||
## Markdown Pages
|
||||
|
||||
Use markdown pages for explanations, design guidance, API notes, and copy standards.
|
||||
|
||||
- Start with frontmatter when the page needs a title or subtitle.
|
||||
- Use sentence case for titles, headings, labels, and UI copy.
|
||||
- Put the live example before the reference API when that makes the page easier to scan.
|
||||
- Use fenced code blocks with a language tag for copyable examples.
|
||||
- Keep examples short and focused on the behavior being documented.
|
||||
- Prefer real component names and attributes over prose-only descriptions.
|
||||
- Use Home Assistant terminology from the root `AGENTS.md`.
|
||||
- For remove/delete and add/create wording, follow `src/pages/misc/remove-delete-add-create.markdown`.
|
||||
|
||||
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, keep those strings aligned with the root localization and copy guidance.
|
||||
|
||||
## Demo Components
|
||||
|
||||
Use TypeScript demo pages for interactive or stateful examples.
|
||||
|
||||
- Import production components from `../../../src/...` or the correct relative path from the demo file.
|
||||
- Import reusable gallery helpers from `gallery/src/components/` when they already model the pattern.
|
||||
- Use `demo-card` and `demo-cards` for Lovelace card examples that render YAML card configs.
|
||||
- Use `demo-more-info` and `demo-more-infos` for more-info dialog examples.
|
||||
- Use shared mock data from `src/data/` instead of repeating large fake state objects inline.
|
||||
- Show meaningful states, such as loading, unavailable, empty, error, active, inactive, and disabled when relevant.
|
||||
- Check responsive behavior and the gallery RTL toggle when layout or direction-sensitive UI changes.
|
||||
- Keep unavoidable casts or loose demo parsing local to the demo helper or demo page.
|
||||
|
||||
The gallery ESLint config allows `console` for gallery diagnostics. Do not copy that exception into production frontend code.
|
||||
|
||||
## Content Standards
|
||||
|
||||
The root copy standards still apply: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
|
||||
|
||||
- Use `Home Assistant` in full, not `HA` or `HASS`.
|
||||
- Use `integration` instead of `component` for product concepts.
|
||||
- Use `Remove` for reversible disassociation and `Delete` for permanent deletion.
|
||||
- Use `Add` for existing items and `Create` for something made from scratch.
|
||||
- Avoid Latin abbreviations like `e.g.` and `i.e.` in prose.
|
||||
- Avoid stitching sentence fragments together in production UI examples.
|
||||
|
||||
## Verification
|
||||
|
||||
- For markdown, sidebar, and page-generation changes, run `gallery/script/build_gallery`.
|
||||
- For TypeScript demo or gallery shell changes, run the smallest relevant check plus `yarn lint` when practical.
|
||||
- For type checking, run `yarn lint:types` without file arguments.
|
||||
- For visual changes, run `gallery/script/develop_gallery` and check the affected page on desktop, narrow viewport, and RTL when relevant.
|
||||
- If verification is skipped, state which command was skipped and why.
|
||||
@@ -1,56 +1,237 @@
|
||||
import {
|
||||
mdiAccountGroup,
|
||||
mdiCalendarClock,
|
||||
mdiDotsHorizontal,
|
||||
mdiHome,
|
||||
mdiInformationOutline,
|
||||
mdiPalette,
|
||||
mdiPuzzle,
|
||||
mdiRobot,
|
||||
mdiViewDashboard,
|
||||
} from "@mdi/js";
|
||||
|
||||
// A group may list its pages flat in `pages`, or group them under named
|
||||
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
|
||||
// any pages found in the category but not listed are appended alphabetically
|
||||
// (to a generated "Other" subsection when the group uses subsections).
|
||||
export default [
|
||||
{
|
||||
// This section has no header and so all page links are shown directly in the sidebar
|
||||
category: "concepts",
|
||||
icon: mdiHome,
|
||||
pages: ["home"],
|
||||
},
|
||||
|
||||
{
|
||||
category: "lovelace",
|
||||
// Label for in the sidebar
|
||||
header: "Dashboards",
|
||||
// Specify order of pages. Any pages in the category folder but not listed here will
|
||||
// automatically be added after the pages listed here.
|
||||
pages: ["introduction"],
|
||||
},
|
||||
{
|
||||
category: "automation",
|
||||
header: "Automation",
|
||||
pages: [
|
||||
"editor-trigger",
|
||||
"editor-condition",
|
||||
"editor-action",
|
||||
"trace",
|
||||
"trace-timeline",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "components",
|
||||
header: "Components",
|
||||
},
|
||||
{
|
||||
category: "more-info",
|
||||
header: "More Info dialogs",
|
||||
},
|
||||
{
|
||||
category: "misc",
|
||||
header: "Miscellaneous",
|
||||
},
|
||||
{
|
||||
category: "brand",
|
||||
icon: mdiPalette,
|
||||
header: "Brand",
|
||||
},
|
||||
{
|
||||
category: "components",
|
||||
icon: mdiPuzzle,
|
||||
header: "Components",
|
||||
subsections: [
|
||||
{
|
||||
header: "Form and selectors",
|
||||
pages: [
|
||||
"ha-form",
|
||||
"ha-selector",
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Controls and sliders",
|
||||
pages: [
|
||||
"ha-button",
|
||||
"ha-control-button",
|
||||
"ha-progress-button",
|
||||
"ha-switch",
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
"ha-control-select-menu",
|
||||
"ha-hs-color-picker",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Overlays",
|
||||
pages: [
|
||||
"ha-dialog",
|
||||
"ha-dialogs",
|
||||
"ha-adaptive-dialog",
|
||||
"ha-adaptive-popover",
|
||||
"ha-dropdown",
|
||||
"ha-tooltip",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Lists and disclosure",
|
||||
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
|
||||
},
|
||||
{
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "lovelace",
|
||||
icon: mdiViewDashboard,
|
||||
// Label for in the sidebar
|
||||
header: "Dashboards",
|
||||
subsections: [
|
||||
{
|
||||
header: "Introduction",
|
||||
pages: ["introduction"],
|
||||
},
|
||||
{
|
||||
header: "Entity cards",
|
||||
pages: [
|
||||
"entities-card",
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Picture cards",
|
||||
pages: [
|
||||
"picture-card",
|
||||
"picture-elements-card",
|
||||
"picture-entity-card",
|
||||
"picture-glance-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Domain cards",
|
||||
pages: [
|
||||
"light-card",
|
||||
"thermostat-card",
|
||||
"alarm-panel-card",
|
||||
"gauge-card",
|
||||
"plant-card",
|
||||
"map-card",
|
||||
"media-control-card",
|
||||
"media-player-row",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Layout and utility",
|
||||
pages: [
|
||||
"grid-and-stack-card",
|
||||
"conditional-card",
|
||||
"iframe-card",
|
||||
"markdown-card",
|
||||
"todo-list-card",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "more-info",
|
||||
icon: mdiInformationOutline,
|
||||
header: "More Info dialogs",
|
||||
subsections: [
|
||||
{
|
||||
header: "Climate and water",
|
||||
pages: ["climate", "humidifier", "water-heater", "fan"],
|
||||
},
|
||||
{
|
||||
header: "Covers and access",
|
||||
pages: ["cover", "lock", "lawn-mower", "vacuum"],
|
||||
},
|
||||
{
|
||||
header: "Lighting",
|
||||
pages: ["light", "scene"],
|
||||
},
|
||||
{
|
||||
header: "Media",
|
||||
pages: ["media-player"],
|
||||
},
|
||||
{
|
||||
header: "Inputs and values",
|
||||
pages: ["input-number", "input-text", "number", "timer"],
|
||||
},
|
||||
{
|
||||
header: "System",
|
||||
pages: ["update"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "automation",
|
||||
icon: mdiRobot,
|
||||
header: "Automation",
|
||||
subsections: [
|
||||
{
|
||||
header: "Editors",
|
||||
pages: ["editor-trigger", "editor-condition", "editor-action"],
|
||||
},
|
||||
{
|
||||
header: "Descriptions",
|
||||
pages: ["describe-trigger", "describe-condition", "describe-action"],
|
||||
},
|
||||
{
|
||||
header: "Traces",
|
||||
pages: ["trace", "trace-timeline"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "user-test",
|
||||
icon: mdiAccountGroup,
|
||||
header: "Users",
|
||||
pages: ["user-types", "configuration-menu"],
|
||||
},
|
||||
{
|
||||
category: "date-time",
|
||||
icon: mdiCalendarClock,
|
||||
header: "Date and Time",
|
||||
subsections: [
|
||||
{
|
||||
header: "Date",
|
||||
pages: ["date"],
|
||||
},
|
||||
{
|
||||
header: "Time",
|
||||
pages: ["time", "time-seconds", "time-weekday"],
|
||||
},
|
||||
{
|
||||
header: "Combined",
|
||||
pages: [
|
||||
"date-time",
|
||||
"date-time-numeric",
|
||||
"date-time-seconds",
|
||||
"date-time-short",
|
||||
"date-time-short-year",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "design.home-assistant.io",
|
||||
header: "About",
|
||||
category: "misc",
|
||||
icon: mdiDotsHorizontal,
|
||||
header: "Miscellaneous",
|
||||
pages: [
|
||||
"entity-state",
|
||||
"ha-markdown",
|
||||
"integration-card",
|
||||
"box-shadow",
|
||||
"util-long-press",
|
||||
"remove-delete-add-create",
|
||||
"editing",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
|
||||
import { extractVars } from "../../../src/common/style/derived-css-vars";
|
||||
import { animationStyles } from "../../../src/resources/theme/animations.globals";
|
||||
import { coreStyles } from "../../../src/resources/theme/core.globals";
|
||||
import { colorStyles } from "../../../src/resources/theme/color/color.globals";
|
||||
import { coreColorStyles } from "../../../src/resources/theme/color/core.globals";
|
||||
import { semanticColorStyles } from "../../../src/resources/theme/color/semantic.globals";
|
||||
import { waColorStyles } from "../../../src/resources/theme/color/wa.globals";
|
||||
import { mainStyles } from "../../../src/resources/theme/main.globals";
|
||||
import { semanticStyles } from "../../../src/resources/theme/semantic.globals";
|
||||
import { typographyStyles } from "../../../src/resources/theme/typography.globals";
|
||||
import { waMainStyles } from "../../../src/resources/theme/wa.globals";
|
||||
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
|
||||
|
||||
export const GALLERY_THEME_STORAGE_KEY = "gallery-theme";
|
||||
|
||||
export const loadGalleryThemeSettings = (): ThemeSettings => {
|
||||
const stored = localStorage.getItem(GALLERY_THEME_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return { theme: "default" };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
const value =
|
||||
parsed && typeof parsed === "object"
|
||||
? (parsed as Partial<ThemeSettings>)
|
||||
: {};
|
||||
return {
|
||||
theme: "default",
|
||||
dark: typeof value.dark === "boolean" ? value.dark : undefined,
|
||||
primaryColor:
|
||||
typeof value.primaryColor === "string" ? value.primaryColor : undefined,
|
||||
accentColor:
|
||||
typeof value.accentColor === "string" ? value.accentColor : undefined,
|
||||
};
|
||||
} catch (_err) {
|
||||
return { theme: "default" };
|
||||
}
|
||||
};
|
||||
|
||||
const LIGHT_THEME_STYLES = [
|
||||
coreStyles,
|
||||
mainStyles,
|
||||
typographyStyles,
|
||||
semanticStyles,
|
||||
coreColorStyles,
|
||||
semanticColorStyles,
|
||||
colorStyles,
|
||||
waColorStyles,
|
||||
waMainStyles,
|
||||
animationStyles,
|
||||
];
|
||||
|
||||
const LIGHT_THEME_VARIABLES = LIGHT_THEME_STYLES.reduce<Record<string, string>>(
|
||||
(variables, style) => {
|
||||
for (const [key, value] of Object.entries(extractVars(style))) {
|
||||
variables[`--${key}`] = value;
|
||||
}
|
||||
return variables;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const LIGHT_THEME_VARIABLE_KEYS = Object.keys(LIGHT_THEME_VARIABLES);
|
||||
const LIGHT_THEME_DEFAULTS_APPLIED = new WeakSet<HTMLElement>();
|
||||
|
||||
export const effectiveGalleryDarkMode = (
|
||||
themeSettings: ThemeSettings,
|
||||
systemDark: boolean
|
||||
): boolean => themeSettings.dark ?? systemDark;
|
||||
|
||||
const galleryThemes = (darkMode: boolean): HomeAssistant["themes"] => ({
|
||||
default_theme: "default",
|
||||
default_dark_theme: null,
|
||||
themes: {},
|
||||
darkMode,
|
||||
theme: "default",
|
||||
});
|
||||
|
||||
const applyLightThemeDefaults = (element: HTMLElement, lightMode: boolean) => {
|
||||
if (lightMode) {
|
||||
for (const [key, value] of Object.entries(LIGHT_THEME_VARIABLES)) {
|
||||
element.style.setProperty(key, value);
|
||||
}
|
||||
LIGHT_THEME_DEFAULTS_APPLIED.add(element);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!LIGHT_THEME_DEFAULTS_APPLIED.has(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of LIGHT_THEME_VARIABLE_KEYS) {
|
||||
element.style.removeProperty(key);
|
||||
}
|
||||
LIGHT_THEME_DEFAULTS_APPLIED.delete(element);
|
||||
};
|
||||
|
||||
export const applyFlippedGalleryTheme = (
|
||||
element: HTMLElement,
|
||||
themeSettings: ThemeSettings,
|
||||
systemDark: boolean
|
||||
) => {
|
||||
const darkMode = !effectiveGalleryDarkMode(themeSettings, systemDark);
|
||||
|
||||
if (!darkMode) {
|
||||
applyThemesOnElement(element, galleryThemes(false), undefined, {
|
||||
dark: false,
|
||||
});
|
||||
applyLightThemeDefaults(element, true);
|
||||
} else {
|
||||
applyLightThemeDefaults(element, false);
|
||||
}
|
||||
|
||||
applyThemesOnElement(element, galleryThemes(darkMode), "default", {
|
||||
...themeSettings,
|
||||
dark: darkMode,
|
||||
});
|
||||
element.style.colorScheme = darkMode ? "dark" : "light";
|
||||
};
|
||||
@@ -1,25 +1,83 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, css, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-button";
|
||||
import type { HaButton } from "../../../src/components/ha-button";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
import {
|
||||
applyFlippedGalleryTheme,
|
||||
effectiveGalleryDarkMode,
|
||||
loadGalleryThemeSettings,
|
||||
} from "../common/theme";
|
||||
|
||||
const mql = matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
@customElement("demo-black-white-row")
|
||||
class DemoBlackWhiteRow extends LitElement {
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property() title!: string;
|
||||
|
||||
@property() value?: any;
|
||||
@property({ attribute: false }) value?: unknown;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _themeSettings = loadGalleryThemeSettings();
|
||||
|
||||
@state() private _systemDark = mql.matches;
|
||||
|
||||
@query(".flipped") private _flipped?: HTMLElement;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
mql.addEventListener("change", this._systemDarkChanged);
|
||||
window.addEventListener(
|
||||
"theme-settings-changed",
|
||||
this._themeSettingsChanged as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
mql.removeEventListener("change", this._systemDarkChanged);
|
||||
window.removeEventListener(
|
||||
"theme-settings-changed",
|
||||
this._themeSettingsChanged as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this._applyFlippedTheme();
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (
|
||||
changedProperties.has("_themeSettings") ||
|
||||
changedProperties.has("_systemDark")
|
||||
) {
|
||||
this._applyFlippedTheme();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const currentLabel = effectiveGalleryDarkMode(
|
||||
this._themeSettings,
|
||||
this._systemDark
|
||||
)
|
||||
? "Dark mode"
|
||||
: "Light mode";
|
||||
const flippedLabel =
|
||||
currentLabel === "Dark mode" ? "Light mode" : "Dark mode";
|
||||
|
||||
return html`
|
||||
<div class="row">
|
||||
<div class="content light">
|
||||
<section class="content current" aria-label=${currentLabel}>
|
||||
<h2>${currentLabel}</h2>
|
||||
<ha-card .header=${this.title}>
|
||||
<div class="card-content">
|
||||
<slot name="light"></slot>
|
||||
@@ -30,8 +88,9 @@ class DemoBlackWhiteRow extends LitElement {
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
<div class="content dark">
|
||||
</section>
|
||||
<section class="content flipped" aria-label=${flippedLabel}>
|
||||
<h2>${flippedLabel}</h2>
|
||||
<ha-card .header=${this.title}>
|
||||
<div class="card-content">
|
||||
<slot name="dark"></slot>
|
||||
@@ -45,65 +104,84 @@ class DemoBlackWhiteRow extends LitElement {
|
||||
${this.value
|
||||
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
|
||||
: nothing}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
handleSubmit(ev: Event) {
|
||||
const content = (ev.target as HaButton).closest(".content");
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSubmit(ev) {
|
||||
const content = (ev.target as HaButton).closest(".content")!;
|
||||
fireEvent(this, "submitted" as any, {
|
||||
slot: content.classList.contains("light") ? "light" : "dark",
|
||||
slot: content.classList.contains("current") ? "light" : "dark",
|
||||
});
|
||||
}
|
||||
|
||||
private _themeSettingsChanged = (
|
||||
ev: HASSDomEvent<Partial<ThemeSettings>>
|
||||
) => {
|
||||
this._themeSettings = {
|
||||
...this._themeSettings,
|
||||
...ev.detail,
|
||||
theme: "default",
|
||||
};
|
||||
};
|
||||
|
||||
private _systemDarkChanged = (ev: MediaQueryListEvent) => {
|
||||
this._systemDark = ev.matches;
|
||||
};
|
||||
|
||||
private _applyFlippedTheme() {
|
||||
if (!this._flipped) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyFlippedGalleryTheme(
|
||||
this._flipped,
|
||||
this._themeSettings,
|
||||
this._systemDark
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
inline-size: 100%;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
.content {
|
||||
padding: 50px 0;
|
||||
box-sizing: border-box;
|
||||
min-inline-size: 0;
|
||||
padding: var(--ha-space-8);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
.light {
|
||||
flex: 1;
|
||||
padding-left: 50px;
|
||||
padding-right: 50px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.light ha-card {
|
||||
margin-left: auto;
|
||||
}
|
||||
.dark {
|
||||
color: var(--primary-text-color);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding-left: 50px;
|
||||
box-sizing: border-box;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
ha-card {
|
||||
width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--primary-text-color);
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
pre {
|
||||
width: 300px;
|
||||
margin: 0 16px 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
@@ -112,27 +190,18 @@ class DemoBlackWhiteRow extends LitElement {
|
||||
flex-direction: row-reverse;
|
||||
border-top: none;
|
||||
}
|
||||
@media only screen and (max-width: 1500px) {
|
||||
.light {
|
||||
flex: initial;
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 1000px) {
|
||||
.light,
|
||||
.dark {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
.row,
|
||||
.dark {
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-card {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
pre {
|
||||
margin: 16px auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../src/components/ha-formfield";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
@@ -16,17 +15,12 @@ class DemoCards extends LitElement {
|
||||
|
||||
@state() private _showConfig = false;
|
||||
|
||||
@query("#container") private _container!: HTMLElement;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<ha-demo-options>
|
||||
<ha-formfield label="Show config">
|
||||
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Dark theme">
|
||||
<ha-switch @change=${this._darkThemeToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
</ha-demo-options>
|
||||
<div id="container">
|
||||
<div class="cards">
|
||||
@@ -48,12 +42,6 @@ class DemoCards extends LitElement {
|
||||
this._showConfig = ev.target.checked;
|
||||
}
|
||||
|
||||
private _darkThemeToggled(ev) {
|
||||
applyThemesOnElement(this._container, { themes: {} } as any, "default", {
|
||||
dark: ev.target.checked,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.cards {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../src/components/ha-formfield";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
@@ -21,9 +20,6 @@ class DemoMoreInfos extends LitElement {
|
||||
<ha-formfield label="Show config">
|
||||
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Dark theme">
|
||||
<ha-switch @change=${this._darkThemeToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
</ha-demo-options>
|
||||
<div id="container">
|
||||
<div class="cards">
|
||||
@@ -51,33 +47,16 @@ class DemoMoreInfos extends LitElement {
|
||||
justify-content: center;
|
||||
}
|
||||
demo-more-info {
|
||||
margin: 16px 16px 32px;
|
||||
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
|
||||
}
|
||||
ha-formfield {
|
||||
margin-right: 16px;
|
||||
margin-right: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
|
||||
private _showConfigToggled(ev) {
|
||||
this._showConfig = ev.target.checked;
|
||||
}
|
||||
|
||||
private _darkThemeToggled(ev) {
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector("#container"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: false,
|
||||
theme: "default",
|
||||
},
|
||||
"default",
|
||||
{
|
||||
dark: ev.target.checked,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
import {
|
||||
applyFlippedGalleryTheme,
|
||||
effectiveGalleryDarkMode,
|
||||
loadGalleryThemeSettings,
|
||||
} from "../common/theme";
|
||||
|
||||
const mql = matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
export const THEME_COMPARISON_PANELS = [
|
||||
{ slot: "current" },
|
||||
{ slot: "flipped" },
|
||||
] as const;
|
||||
|
||||
@customElement("demo-theme-comparison")
|
||||
export class DemoThemeComparison extends LitElement {
|
||||
@state() private _themeSettings = loadGalleryThemeSettings();
|
||||
|
||||
@state() private _systemDark = mql.matches;
|
||||
|
||||
@query(".flipped") private _flipped?: HTMLElement;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
mql.addEventListener("change", this._systemDarkChanged);
|
||||
window.addEventListener(
|
||||
"theme-settings-changed",
|
||||
this._themeSettingsChanged as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
mql.removeEventListener("change", this._systemDarkChanged);
|
||||
window.removeEventListener(
|
||||
"theme-settings-changed",
|
||||
this._themeSettingsChanged as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this._applyFlippedTheme();
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (
|
||||
changedProperties.has("_themeSettings") ||
|
||||
changedProperties.has("_systemDark")
|
||||
) {
|
||||
this._applyFlippedTheme();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const currentLabel = effectiveGalleryDarkMode(
|
||||
this._themeSettings,
|
||||
this._systemDark
|
||||
)
|
||||
? "Dark mode"
|
||||
: "Light mode";
|
||||
const flippedLabel =
|
||||
currentLabel === "Dark mode" ? "Light mode" : "Dark mode";
|
||||
|
||||
return html`
|
||||
<section class="panel" aria-label=${currentLabel}>
|
||||
<h2>${currentLabel}</h2>
|
||||
<slot name="current"></slot>
|
||||
</section>
|
||||
<section class="panel flipped" aria-label=${flippedLabel}>
|
||||
<h2>${flippedLabel}</h2>
|
||||
<slot name="flipped"></slot>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private _themeSettingsChanged = (
|
||||
ev: HASSDomEvent<Partial<ThemeSettings>>
|
||||
) => {
|
||||
this._themeSettings = {
|
||||
...this._themeSettings,
|
||||
...ev.detail,
|
||||
theme: "default",
|
||||
};
|
||||
};
|
||||
|
||||
private _systemDarkChanged = (ev: MediaQueryListEvent) => {
|
||||
this._systemDark = ev.matches;
|
||||
};
|
||||
|
||||
private _applyFlippedTheme() {
|
||||
if (!this._flipped) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyFlippedGalleryTheme(
|
||||
this._flipped,
|
||||
this._themeSettings,
|
||||
this._systemDark
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
inline-size: 100%;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
box-sizing: border-box;
|
||||
min-block-size: 100%;
|
||||
min-inline-size: 0;
|
||||
padding: var(--ha-space-6);
|
||||
background-color: var(--primary-background-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
|
||||
::slotted(*) {
|
||||
box-sizing: border-box;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1000px) {
|
||||
:host {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-theme-comparison": DemoThemeComparison;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-settings-row";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import "../../../src/components/ha-theme-settings";
|
||||
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
|
||||
|
||||
@customElement("gallery-settings")
|
||||
class GallerySettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public themeSettings!: ThemeSettings;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean }) public rtl = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-card .header=${"Appearance"}>
|
||||
<div class="card-content">
|
||||
Configure how the gallery renders component previews and examples.
|
||||
</div>
|
||||
<ha-theme-settings
|
||||
.hass=${this.hass}
|
||||
.selectedTheme=${this.themeSettings}
|
||||
.narrow=${this.narrow}
|
||||
.heading=${"Theme"}
|
||||
.description=${"Choose the mode and colors used throughout the gallery."}
|
||||
.labels=${{
|
||||
mode: "Theme mode",
|
||||
autoMode: "Auto",
|
||||
lightMode: "Light",
|
||||
darkMode: "Dark",
|
||||
primaryColor: "Primary color",
|
||||
accentColor: "Accent color",
|
||||
reset: "Reset",
|
||||
}}
|
||||
.showThemePicker=${false}
|
||||
></ha-theme-settings>
|
||||
<ha-settings-row .narrow=${this.narrow}>
|
||||
<span slot="heading">Right-to-left layout</span>
|
||||
<span slot="description">
|
||||
Preview the gallery with right-to-left text direction.
|
||||
</span>
|
||||
<ha-switch
|
||||
.checked=${this.rtl}
|
||||
@change=${this._rtlChanged}
|
||||
></ha-switch>
|
||||
</ha-settings-row>
|
||||
</ha-card>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _rtlChanged(ev: Event) {
|
||||
fireEvent(this, "gallery-rtl-changed", {
|
||||
rtl: (ev.currentTarget as HaSwitch).checked,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"gallery-rtl-changed": { rtl: boolean };
|
||||
}
|
||||
|
||||
interface HTMLElementTagNameMap {
|
||||
"gallery-settings": GallerySettings;
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,10 @@ class PageDescription extends HaMarkdown {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const subtitle = PAGES[this.page].metadata.subtitle;
|
||||
|
||||
return html`
|
||||
<div class="heading">
|
||||
<div class="title">
|
||||
${PAGES[this.page].metadata.title || this.page.split("/")[1]}
|
||||
</div>
|
||||
<div class="subtitle">${PAGES[this.page].metadata.subtitle}</div>
|
||||
</div>
|
||||
${subtitle ? html`<div class="subtitle">${subtitle}</div>` : nothing}
|
||||
${until(
|
||||
PAGES[this.page]
|
||||
.description()
|
||||
@@ -32,16 +29,9 @@ class PageDescription extends HaMarkdown {
|
||||
static styles = [
|
||||
HaMarkdown.styles,
|
||||
css`
|
||||
.heading {
|
||||
.subtitle {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--secondary-background-color);
|
||||
}
|
||||
.title {
|
||||
font-size: 42px;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: var(--ha-font-size-l);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "@material/mwc-drawer";
|
||||
import "@material/mwc-top-app-bar-fixed";
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../../src/components/ha-icon-button";
|
||||
@@ -18,22 +16,9 @@ class HaDemoOptions extends LitElement {
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
background-color: var(--light-primary-color);
|
||||
margin-left: 60px
|
||||
margin-right: 60px;
|
||||
display: var(--layout-horizontal_-_display);
|
||||
-ms-flex-direction: var(--layout-horizontal_-_-ms-flex-direction);
|
||||
-webkit-flex-direction: var(
|
||||
--layout-horizontal_-_-webkit-flex-direction
|
||||
);
|
||||
flex-direction: var(--layout-horizontal_-_flex-direction);
|
||||
-ms-flex-align: var(--layout-center_-_-ms-flex-align);
|
||||
-webkit-align-items: var(--layout-center_-_-webkit-align-items);
|
||||
align-items: var(--layout-center_-_align-items);
|
||||
background-color: var(--primary-background-color);
|
||||
position: relative;
|
||||
height: 64px;
|
||||
padding: 0 16px;
|
||||
pointer-events: none;
|
||||
padding: var(--ha-space-2) var(--ha-space-16) var(--ha-space-1);
|
||||
font-size: var(--ha-font-size-xl);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -1,161 +1,194 @@
|
||||
import "@material/mwc-drawer";
|
||||
import "@material/mwc-top-app-bar-fixed";
|
||||
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
|
||||
import { mdiCog, mdiMenu } from "@mdi/js";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
import "../../src/components/ha-drawer";
|
||||
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
|
||||
import "../../src/components/ha-icon-button";
|
||||
import "../../src/components/ha-sidebar";
|
||||
import "../../src/components/item/ha-list-item-button";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/ha-top-app-bar-fixed";
|
||||
import "../../src/managers/notification-manager";
|
||||
import { haStyle } from "../../src/resources/styles";
|
||||
import type { HomeAssistant, ThemeSettings } from "../../src/types";
|
||||
import { PAGES, SIDEBAR } from "../build/import-pages";
|
||||
import {
|
||||
GALLERY_THEME_STORAGE_KEY,
|
||||
loadGalleryThemeSettings,
|
||||
} from "./common/theme";
|
||||
import "./components/gallery-settings";
|
||||
import "./components/page-description";
|
||||
|
||||
const RTL_STORAGE_KEY = "gallery-rtl";
|
||||
const SETTINGS_PAGE = "settings";
|
||||
|
||||
const GITHUB_DEMO_URL =
|
||||
"https://github.com/home-assistant/frontend/blob/dev/gallery/src/pages/";
|
||||
|
||||
const FAKE_HASS = {
|
||||
// Just enough for computeRTL for notification-manager
|
||||
language: "en",
|
||||
translationMetadata: {
|
||||
translations: {},
|
||||
interface GalleryPage {
|
||||
metadata: Record<string, unknown>;
|
||||
description?: unknown;
|
||||
demo?: unknown;
|
||||
}
|
||||
|
||||
interface GallerySidebarSubsection {
|
||||
header: string;
|
||||
pages: string[];
|
||||
}
|
||||
|
||||
interface GallerySidebarGroup {
|
||||
category: string;
|
||||
header?: string;
|
||||
icon?: string;
|
||||
pages?: string[];
|
||||
subsections?: GallerySidebarSubsection[];
|
||||
}
|
||||
|
||||
const groupPages = (group: GallerySidebarGroup): string[] =>
|
||||
group.subsections
|
||||
? group.subsections.flatMap((subsection) => subsection.pages)
|
||||
: (group.pages ?? []);
|
||||
|
||||
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
|
||||
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
|
||||
|
||||
const mql = matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const galleryLocalize = (key: string) =>
|
||||
(
|
||||
({
|
||||
"ui.sidebar.sidebar_toggle": "Toggle sidebar",
|
||||
"ui.notification_drawer.title": "Notifications",
|
||||
"ui.sidebar.external_app_configuration": "App configuration",
|
||||
"panel.config": "Settings",
|
||||
}) as Record<string, string>
|
||||
)[key] ?? key;
|
||||
|
||||
const galleryConnection = {
|
||||
subscribeMessage(
|
||||
callback: (message: unknown) => void,
|
||||
message: { type?: string }
|
||||
) {
|
||||
if (message.type === "frontend/subscribe_user_data") {
|
||||
callback({ value: { panelOrder: [], hiddenPanels: [] } });
|
||||
} else if (message.type === "persistent_notification/subscribe") {
|
||||
callback({ type: "current", notifications: {} });
|
||||
}
|
||||
return Promise.resolve(() => undefined);
|
||||
},
|
||||
};
|
||||
sendMessagePromise() {
|
||||
return Promise.resolve({ value: null });
|
||||
},
|
||||
} as unknown as Connection;
|
||||
|
||||
@customElement("ha-gallery")
|
||||
class HaGallery extends LitElement {
|
||||
@state() private _page =
|
||||
document.location.hash.substring(1) ||
|
||||
`${SIDEBAR[0].category}/${SIDEBAR[0].pages![0]}`;
|
||||
@state() private _page = this._pageFromLocation();
|
||||
|
||||
@state() private _rtl = localStorage.getItem(RTL_STORAGE_KEY) === "true";
|
||||
|
||||
@state() private _themeSettings = loadGalleryThemeSettings();
|
||||
|
||||
@state() private _systemDark = mql.matches;
|
||||
|
||||
@query("notification-manager")
|
||||
private _notifications!: HTMLElementTagNameMap["notification-manager"];
|
||||
|
||||
@query("mwc-drawer")
|
||||
private _drawer!: HTMLElementTagNameMap["mwc-drawer"];
|
||||
@query("ha-sidebar")
|
||||
private _sidebar?: HTMLElementTagNameMap["ha-sidebar"];
|
||||
|
||||
@query(".gallery-nav-item[selected]")
|
||||
private _selectedNavigationItem?: HTMLElementTagNameMap["ha-list-item-button"];
|
||||
|
||||
private _narrow = window.matchMedia("(max-width: 600px)").matches;
|
||||
|
||||
@state() private _drawerOpen = !this._narrow;
|
||||
|
||||
render() {
|
||||
const sidebar: unknown[] = [];
|
||||
|
||||
for (const group of SIDEBAR) {
|
||||
const links: unknown[] = [];
|
||||
|
||||
for (const page of group.pages!) {
|
||||
const key = `${group.category}/${page}`;
|
||||
const active = this._page === key;
|
||||
if (!(key in PAGES)) {
|
||||
console.error("Undefined page referenced in sidebar.js:", key);
|
||||
continue;
|
||||
}
|
||||
const title = PAGES[key].metadata.title || page;
|
||||
links.push(html`
|
||||
<a ?active=${active} href=${`#${group.category}/${page}`}>${title}</a>
|
||||
`);
|
||||
}
|
||||
|
||||
sidebar.push(
|
||||
group.header
|
||||
? html`
|
||||
<ha-expansion-panel .header=${group.header}>
|
||||
${links}
|
||||
</ha-expansion-panel>
|
||||
`
|
||||
: links
|
||||
);
|
||||
}
|
||||
const isSettingsPage = this._page === SETTINGS_PAGE;
|
||||
const page = isSettingsPage ? undefined : PAGES[this._page];
|
||||
|
||||
return html`
|
||||
<mwc-drawer
|
||||
hasHeader
|
||||
.open=${!this._narrow}
|
||||
<ha-drawer
|
||||
.direction=${this._rtl ? "rtl" : "ltr"}
|
||||
.open=${this._drawerOpen}
|
||||
.type=${this._narrow ? "modal" : "dismissible"}
|
||||
>
|
||||
<span slot="title">Home Assistant Design</span>
|
||||
<!-- <span slot="subtitle">subtitle</span> -->
|
||||
<div class="sidebar">${sidebar}</div>
|
||||
<div slot="appContent">
|
||||
<mwc-top-app-bar-fixed>
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this._menuTapped}
|
||||
.path=${mdiMenu}
|
||||
></ha-icon-button>
|
||||
<ha-sidebar
|
||||
.hass=${this._galleryHass}
|
||||
.narrow=${this._narrow}
|
||||
.route=${{ prefix: "", path: this._page }}
|
||||
.alwaysExpand=${true}
|
||||
sidebar-title="Home Assistant Design"
|
||||
@hass-toggle-menu=${this._toggleDrawer}
|
||||
>
|
||||
${this._renderSidebarNavigation()} ${this._renderSettingsItem()}
|
||||
</ha-sidebar>
|
||||
<div slot="appContent" class="app-content">
|
||||
<ha-top-app-bar-fixed .narrow=${this._narrow}>
|
||||
${this._narrow || !this._drawerOpen
|
||||
? html`<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this._toggleDrawer}
|
||||
.path=${mdiMenu}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
|
||||
<div slot="title">
|
||||
${PAGES[this._page].metadata.title || this._page.split("/")[1]}
|
||||
${isSettingsPage
|
||||
? "Settings"
|
||||
: page?.metadata.title || this._page.split("/")[1]}
|
||||
</div>
|
||||
</mwc-top-app-bar-fixed>
|
||||
<div class="content">
|
||||
${PAGES[this._page].description
|
||||
? html`
|
||||
<page-description .page=${this._page}></page-description>
|
||||
`
|
||||
: ""}
|
||||
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
|
||||
</div>
|
||||
<div class="page-footer">
|
||||
<div class="edit-docs">
|
||||
<div class="header">Help us to improve our documentation</div>
|
||||
<div class="secondary">
|
||||
Suggest an edit to this page, or provide/view feedback for this
|
||||
page.
|
||||
</div>
|
||||
<div>
|
||||
${PAGES[this._page].description ||
|
||||
Object.keys(PAGES[this._page].metadata).length > 0
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit text
|
||||
</a>
|
||||
`
|
||||
: ""}
|
||||
${PAGES[this._page].demo
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit demo
|
||||
</a>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="content">
|
||||
${isSettingsPage
|
||||
? html`<gallery-settings
|
||||
.hass=${this._galleryHass}
|
||||
.themeSettings=${this._themeSettings}
|
||||
.narrow=${this._narrow}
|
||||
.rtl=${this._rtl}
|
||||
@theme-settings-changed=${this._themeSettingsChanged}
|
||||
@gallery-rtl-changed=${this._rtlChanged}
|
||||
></gallery-settings>`
|
||||
: html`
|
||||
${page?.description
|
||||
? html`
|
||||
<page-description .page=${this._page}>
|
||||
</page-description>
|
||||
`
|
||||
: nothing}
|
||||
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
|
||||
`}
|
||||
</div>
|
||||
<div class="rtl-toggle">
|
||||
<ha-icon-button
|
||||
@click=${this._toggleRtl}
|
||||
.label=${this._rtl ? "Switch to LTR" : "Switch to RTL"}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>
|
||||
</ha-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
${isSettingsPage || !page ? nothing : this._renderPageFooter(page)}
|
||||
</ha-top-app-bar-fixed>
|
||||
</div>
|
||||
</mwc-drawer>
|
||||
</ha-drawer>
|
||||
<notification-manager
|
||||
.hass=${FAKE_HASS}
|
||||
.hass=${this._galleryHass}
|
||||
id="notifications"
|
||||
></notification-manager>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
mql.addEventListener("change", this._systemDarkChanged);
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
this._applyDirection();
|
||||
this._applyTheme();
|
||||
|
||||
this.addEventListener("show-notification", (ev) =>
|
||||
this._notifications.showDialog({ message: ev.detail.message })
|
||||
@@ -171,16 +204,26 @@ class HaGallery extends LitElement {
|
||||
}
|
||||
});
|
||||
|
||||
document.location.hash = this._page;
|
||||
if (document.location.hash.substring(1) !== this._page) {
|
||||
document.location.hash = this._page;
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
this._page = document.location.hash.substring(1);
|
||||
if (this._narrow) {
|
||||
this._drawer.open = false;
|
||||
}
|
||||
});
|
||||
window.addEventListener("hashchange", this._hashChanged);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
mql.removeEventListener("change", this._systemDarkChanged);
|
||||
window.removeEventListener("hashchange", this._hashChanged);
|
||||
}
|
||||
|
||||
private _hashChanged = () => {
|
||||
this._page = this._pageFromLocation();
|
||||
if (this._narrow) {
|
||||
this._drawerOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
@@ -188,37 +231,354 @@ class HaGallery extends LitElement {
|
||||
this._applyDirection();
|
||||
}
|
||||
|
||||
if (changedProps.has("_themeSettings") || changedProps.has("_systemDark")) {
|
||||
this._applyTheme();
|
||||
}
|
||||
|
||||
if (!changedProps.has("_page")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._page === SETTINGS_PAGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (PAGES[this._page].demo) {
|
||||
PAGES[this._page].demo();
|
||||
}
|
||||
|
||||
const menuItem = this.shadowRoot!.querySelector(
|
||||
`a[href="#${this._page}"]`
|
||||
)!;
|
||||
void this._scrollSelectedNavigationItemIntoView();
|
||||
}
|
||||
|
||||
// Make sure section is expanded
|
||||
private async _scrollSelectedNavigationItemIntoView() {
|
||||
const menuItem = this._selectedNavigationItem;
|
||||
|
||||
if (!menuItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure section is expanded before measuring the selected item.
|
||||
if (menuItem.parentElement instanceof HaExpansionPanel) {
|
||||
menuItem.parentElement.expanded = true;
|
||||
await menuItem.parentElement.updateComplete;
|
||||
}
|
||||
|
||||
const scrollable = this._sidebar?.shadowRoot?.querySelector<HTMLElement>(
|
||||
"ha-list-nav.before-spacer"
|
||||
);
|
||||
|
||||
if (!scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const itemRect = menuItem.getBoundingClientRect();
|
||||
const scrollableRect = scrollable.getBoundingClientRect();
|
||||
const targetScrollTop =
|
||||
scrollable.scrollTop +
|
||||
itemRect.top -
|
||||
scrollableRect.top -
|
||||
(scrollableRect.height - itemRect.height) / 2;
|
||||
|
||||
scrollable.scrollTo({
|
||||
top: Math.min(
|
||||
Math.max(0, targetScrollTop),
|
||||
scrollable.scrollHeight - scrollable.clientHeight
|
||||
),
|
||||
left: 0,
|
||||
});
|
||||
scrollable.scrollLeft = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private _menuTapped() {
|
||||
this._drawer.open = !this._drawer.open;
|
||||
private _renderSidebarNavigation() {
|
||||
const sidebar: unknown[] = [];
|
||||
|
||||
for (const group of GALLERY_SIDEBAR) {
|
||||
const expanded = groupPages(group).some(
|
||||
(page) => this._page === `${group.category}/${page}`
|
||||
);
|
||||
|
||||
const content = group.subsections
|
||||
? group.subsections.map((subsection) =>
|
||||
this._renderSidebarSubsection(group, subsection)
|
||||
)
|
||||
: this._renderPageLinks(group, group.pages ?? []);
|
||||
|
||||
sidebar.push(
|
||||
group.header
|
||||
? html`
|
||||
<ha-expansion-panel
|
||||
slot="main-navigation"
|
||||
class="gallery-sidebar-section"
|
||||
.header=${group.header}
|
||||
?expanded=${expanded}
|
||||
>
|
||||
${group.icon
|
||||
? html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
class="gallery-sidebar-icon"
|
||||
.path=${group.icon}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
${content}
|
||||
</ha-expansion-panel>
|
||||
`
|
||||
: content
|
||||
);
|
||||
}
|
||||
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
private _toggleRtl() {
|
||||
this._rtl = !this._rtl;
|
||||
localStorage.setItem(RTL_STORAGE_KEY, String(this._rtl));
|
||||
private _renderSidebarSubsection(
|
||||
group: GallerySidebarGroup,
|
||||
subsection: GallerySidebarSubsection
|
||||
) {
|
||||
return html`
|
||||
<div class="gallery-sidebar-subheader">${subsection.header}</div>
|
||||
${this._renderPageLinks(group, subsection.pages)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
|
||||
const links: unknown[] = [];
|
||||
for (const page of pages) {
|
||||
const key = `${group.category}/${page}`;
|
||||
if (!(key in PAGES)) {
|
||||
console.error("Undefined page referenced in sidebar.js:", key);
|
||||
continue;
|
||||
}
|
||||
links.push(
|
||||
this._renderPageLink(
|
||||
key,
|
||||
PAGES[key].metadata.title || page,
|
||||
group.header ? undefined : "main-navigation",
|
||||
group.header ? undefined : group.icon
|
||||
)
|
||||
);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
private _renderPageLink(
|
||||
page: string,
|
||||
title: string,
|
||||
slot?: string,
|
||||
iconPath?: string
|
||||
) {
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
slot=${ifDefined(slot)}
|
||||
class=${classMap({
|
||||
"gallery-nav-item": true,
|
||||
"has-icon": Boolean(iconPath),
|
||||
selected: this._page === page,
|
||||
})}
|
||||
?selected=${this._page === page}
|
||||
href=${`#${page}`}
|
||||
>
|
||||
${iconPath
|
||||
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
|
||||
: nothing}
|
||||
<span slot="headline">${title}</span>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSettingsItem() {
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
slot="fixed-navigation"
|
||||
class=${classMap({
|
||||
"gallery-settings-item": true,
|
||||
selected: this._page === SETTINGS_PAGE,
|
||||
})}
|
||||
?selected=${this._page === SETTINGS_PAGE}
|
||||
href="#settings"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
|
||||
<span slot="headline">Settings</span>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPageFooter(page: GalleryPage) {
|
||||
return html`<div class="page-footer">
|
||||
<div class="edit-docs">
|
||||
<div class="header">Help us to improve our documentation</div>
|
||||
<div class="secondary">
|
||||
Suggest an edit to this page, or provide/view feedback for this page.
|
||||
</div>
|
||||
<div>
|
||||
${page.description || Object.keys(page.metadata).length > 0
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit text
|
||||
</a>
|
||||
`
|
||||
: nothing}
|
||||
${page.demo
|
||||
? html`
|
||||
<a href=${`${GITHUB_DEMO_URL}${this._page}.ts`} target="_blank">
|
||||
Edit demo
|
||||
</a>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _toggleDrawer(ev?: Event) {
|
||||
ev?.stopPropagation();
|
||||
this._drawerOpen = !this._drawerOpen;
|
||||
}
|
||||
|
||||
private _applyDirection() {
|
||||
setDirectionStyles(this._rtl ? "rtl" : "ltr", this);
|
||||
}
|
||||
|
||||
private _themeSettingsChanged(ev: HASSDomEvent<Partial<ThemeSettings>>) {
|
||||
this._themeSettings = {
|
||||
...this._themeSettings,
|
||||
...ev.detail,
|
||||
theme: "default",
|
||||
};
|
||||
localStorage.setItem(
|
||||
GALLERY_THEME_STORAGE_KEY,
|
||||
JSON.stringify(this._themeSettings)
|
||||
);
|
||||
}
|
||||
|
||||
private _rtlChanged(ev: HASSDomEvent<{ rtl: boolean }>) {
|
||||
this._rtl = ev.detail.rtl;
|
||||
localStorage.setItem(RTL_STORAGE_KEY, String(this._rtl));
|
||||
}
|
||||
|
||||
private _systemDarkChanged = (ev: MediaQueryListEvent) => {
|
||||
this._systemDark = ev.matches;
|
||||
};
|
||||
|
||||
private _applyTheme() {
|
||||
applyThemesOnElement(
|
||||
document.documentElement,
|
||||
this._themes,
|
||||
"default",
|
||||
this._themeSettings,
|
||||
true
|
||||
);
|
||||
|
||||
let schemeMeta = document.querySelector("meta[name=color-scheme]");
|
||||
if (!schemeMeta) {
|
||||
schemeMeta = document.createElement("meta");
|
||||
schemeMeta.setAttribute("name", "color-scheme");
|
||||
document.head.appendChild(schemeMeta);
|
||||
}
|
||||
schemeMeta.setAttribute(
|
||||
"content",
|
||||
this._effectiveDarkMode ? "dark" : "light"
|
||||
);
|
||||
document.documentElement.style.colorScheme = this._effectiveDarkMode
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const themeMeta = document.querySelector("meta[name=theme-color]");
|
||||
if (themeMeta) {
|
||||
if (!themeMeta.hasAttribute("default-content")) {
|
||||
themeMeta.setAttribute(
|
||||
"default-content",
|
||||
themeMeta.getAttribute("content") ?? ""
|
||||
);
|
||||
}
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const themeColor =
|
||||
styles.getPropertyValue("--app-theme-color").trim() ||
|
||||
styles.getPropertyValue("--primary-background-color").trim() ||
|
||||
themeMeta.getAttribute("default-content") ||
|
||||
"";
|
||||
themeMeta.setAttribute("content", themeColor);
|
||||
}
|
||||
}
|
||||
|
||||
private _pageFromLocation() {
|
||||
const page = document.location.hash.substring(1);
|
||||
return page === SETTINGS_PAGE || page in PAGES ? page : DEFAULT_PAGE;
|
||||
}
|
||||
|
||||
private get _effectiveDarkMode() {
|
||||
return this._themeSettings.dark ?? this._systemDark;
|
||||
}
|
||||
|
||||
private get _themes(): HomeAssistant["themes"] {
|
||||
return {
|
||||
default_theme: "default",
|
||||
default_dark_theme: null,
|
||||
themes: {},
|
||||
darkMode: this._effectiveDarkMode,
|
||||
theme: "default",
|
||||
};
|
||||
}
|
||||
|
||||
private get _galleryHass(): HomeAssistant {
|
||||
return {
|
||||
auth: {},
|
||||
areas: {},
|
||||
config: {},
|
||||
connected: true,
|
||||
connection: galleryConnection,
|
||||
debugConnection: false,
|
||||
devices: {},
|
||||
dockedSidebar: "docked",
|
||||
enableShortcuts: true,
|
||||
entities: {},
|
||||
floors: {},
|
||||
hassUrl: (path) => path,
|
||||
kioskMode: false,
|
||||
language: "en",
|
||||
loadBackendTranslation: async () => galleryLocalize,
|
||||
loadFragmentTranslation: async () => undefined,
|
||||
locale: {
|
||||
language: "en",
|
||||
number_format: "language",
|
||||
time_format: "language",
|
||||
date_format: "language",
|
||||
first_weekday: "language",
|
||||
time_zone: "local",
|
||||
},
|
||||
localize: galleryLocalize,
|
||||
panelUrl: this._page,
|
||||
panels: {},
|
||||
selectedLanguage: null,
|
||||
selectedTheme: this._themeSettings,
|
||||
services: {},
|
||||
states: {},
|
||||
suspendWhenHidden: false,
|
||||
systemData: {},
|
||||
themes: this._themes,
|
||||
translationMetadata: { fragments: [], translations: {} },
|
||||
user: {
|
||||
id: "gallery",
|
||||
is_admin: false,
|
||||
is_owner: false,
|
||||
name: "Settings",
|
||||
credentials: [],
|
||||
mfa_modules: [],
|
||||
},
|
||||
userData: {},
|
||||
vibrate: false,
|
||||
callApi: async () => undefined,
|
||||
callApiRaw: async () => new Response(),
|
||||
callService: async () => ({ context: { id: "gallery" } }),
|
||||
callWS: async () => undefined,
|
||||
fetchWithAuth: async () => new Response(),
|
||||
sendWS: () => undefined,
|
||||
} as unknown as HomeAssistant;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
@@ -226,48 +586,137 @@ class HaGallery extends LitElement {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
--ha-sidebar-width: 300px;
|
||||
--ha-sidebar-expanded-width: 300px;
|
||||
--ha-sidebar-expanded-item-width: 292px;
|
||||
--ha-sidebar-expanded-section-item-width: 256px;
|
||||
--app-header-background-color: var(--sidebar-background-color);
|
||||
--app-header-text-color: var(--sidebar-text-color);
|
||||
--app-header-border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 4px;
|
||||
.gallery-sidebar-section {
|
||||
color: var(--sidebar-text-color);
|
||||
box-sizing: border-box;
|
||||
margin: 0 var(--ha-space-1) var(--ha-space-1);
|
||||
overflow-x: hidden;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--expansion-panel-summary-padding: 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
color: var(--primary-text-color);
|
||||
display: block;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
.gallery-sidebar-section::part(summary) {
|
||||
min-height: var(--ha-space-10);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gallery-sidebar-section .gallery-nav-item {
|
||||
margin-inline-start: var(--ha-space-4);
|
||||
width: var(--ha-sidebar-expanded-section-item-width, 248px);
|
||||
}
|
||||
|
||||
.gallery-sidebar-subheader {
|
||||
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.gallery-sidebar-icon,
|
||||
.gallery-nav-item ha-svg-icon[slot="start"] {
|
||||
color: var(--sidebar-icon-color);
|
||||
flex-shrink: 0;
|
||||
height: var(--ha-space-6);
|
||||
width: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.gallery-sidebar-icon {
|
||||
margin-inline-end: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.gallery-nav-item,
|
||||
.gallery-settings-item {
|
||||
flex-shrink: 0;
|
||||
margin: 0 var(--ha-space-1) var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--ha-row-item-min-height: var(--ha-space-10);
|
||||
--ha-row-item-padding-block: 0;
|
||||
--ha-row-item-padding-inline: var(--ha-space-3);
|
||||
position: relative;
|
||||
width: var(--ha-sidebar-expanded-item-width, 248px);
|
||||
color: var(--sidebar-text-color);
|
||||
}
|
||||
|
||||
.sidebar a[active]::before {
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
.gallery-nav-item.has-icon,
|
||||
.gallery-settings-item {
|
||||
--ha-row-item-gap: var(--ha-space-3);
|
||||
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-3);
|
||||
}
|
||||
|
||||
.gallery-nav-item::part(headline),
|
||||
.gallery-settings-item::part(headline) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.gallery-nav-item[selected],
|
||||
.gallery-settings-item[selected] {
|
||||
color: var(--sidebar-selected-icon-color);
|
||||
}
|
||||
|
||||
.gallery-nav-item[selected]::before,
|
||||
.gallery-settings-item[selected]::before {
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 2px;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
transition: opacity 15ms linear;
|
||||
will-change: opacity;
|
||||
background-color: var(--sidebar-selected-icon-color);
|
||||
opacity: 0.12;
|
||||
opacity: var(--dark-divider-opacity);
|
||||
}
|
||||
|
||||
div[slot="appContent"] {
|
||||
.gallery-settings-item ha-svg-icon[slot="start"] {
|
||||
color: var(--sidebar-icon-color);
|
||||
flex-shrink: 0;
|
||||
height: var(--ha-space-6);
|
||||
width: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.gallery-settings-item[selected] ha-svg-icon[slot="start"] {
|
||||
color: var(--sidebar-selected-icon-color);
|
||||
}
|
||||
|
||||
.gallery-nav-item[selected] ha-svg-icon[slot="start"] {
|
||||
color: var(--sidebar-selected-icon-color);
|
||||
}
|
||||
|
||||
.app-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--primary-background-color);
|
||||
}
|
||||
|
||||
ha-drawer[type="dismissible"][open] ha-top-app-bar-fixed {
|
||||
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
|
||||
}
|
||||
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
page-description {
|
||||
margin: 16px;
|
||||
display: block;
|
||||
margin: 0 var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
@@ -304,18 +753,6 @@ class HaGallery extends LitElement {
|
||||
margin: 0 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rtl-toggle {
|
||||
padding: var(--ha-space-4);
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
|
||||
.rtl-toggle ha-icon-button {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "Brand Personality"
|
||||
---
|
||||
|
||||
# Brand Personality
|
||||
|
||||
These five traits describe who Home Assistant is, not how it speaks. Tone of voice – the playfulness, the informality, the warmth, etc – should flow naturally from these, and will help guide writers on how to bring the brand personality to life.
|
||||
|
||||
The first four traits are relational: they describe how Home Assistant behaves toward its users.
|
||||
_Welcoming_ is about how we receive them.\
|
||||
_Candid_ is about how we communicate with them.\
|
||||
_Supportive_ is about how we help them.\
|
||||
_Generous_ is about how/what we give to them.\
|
||||
If any of these feel similar, it’s because they’re all expressions of the same underlying character, just in different moments of the user relationship.
|
||||
|
||||
_Independent_ is different. It’s foundational: it describes who Home Assistant is at its core.\
|
||||
And it’s because of that independence that the other four traits feel genuine rather than performed. A corporate brand can try to be welcoming, candid, supportive, and generous,
|
||||
but without independence, those traits will always be managed and moderated.
|
||||
|
||||
## Welcoming
|
||||
|
||||
**Warm and open, kind, friendly, approachable, accommodating**\
|
||||
_But not: people pleasing, appeasing, sycophantic, ingratiating_\
|
||||
Home Assistant feels like a knowledgeable friend, not a product. We meet you at your own level, never talk down to you, and make you feel valued regardless of your technical ability. This isn’t performative, it’s expressed naturally in the small things: how errors are explained, documentation is written, and how the community talks to newcomers.
|
||||
|
||||
## Candid
|
||||
|
||||
**Direct, honest, transparent, unpretentious**\
|
||||
_But not: unfriendly, rude, blunt, unempathetic_\
|
||||
Home Assistant says what it means. We don’t hide complexity behind false simplicity, fall back on marketing fluff, or pretend limitations don’t exist. We respect users enough to be straight with them: about what Home Assistant can do, what it can't, and why it works the way it does. This is what builds real trust with users.
|
||||
|
||||
## Supportive
|
||||
|
||||
**Helpful, guiding, encouraging, empathetic, genuine**\
|
||||
_But not: directive, hollow, condescending, over-bearing_\
|
||||
Home Assistant always has your back. Whether you’re just starting out or deep into a complex setup, we steer you forward without taking over. Our support is genuine: practical, patient, and there when it’s needed most. Because Home Assistant wants every user to succeed in building a smart home with privacy, choice, and sustainability at its heart.
|
||||
|
||||
## Generous
|
||||
|
||||
**Empowering, trusting, giving, sharing**\
|
||||
_But not: overwhelming, indiscriminate, patronizing, controlling_\
|
||||
Home Assistant gives you everything you need today, and as your setup evolves. There are no strings attached: no artificial limits, features locked behind tiers, or deceptive patterns designed to tie you to a closed platform. We trust users with control, access, and transparency. This generosity is reciprocal: the time, knowledge, and care our community gives freely is what keeps Home Assistant thriving and truly open.
|
||||
|
||||
## Independent
|
||||
|
||||
**Principled, liberated, confident, imperfect**\
|
||||
_But not: conceited, obstinate, erratic, insular_\
|
||||
Home Assistant doesn’t feel the need to behave like an established tech brand or follow corporate rules. With no shareholders or VCs to answer to, we can say what we think, do things our own way, and not take ourselves too seriously. This freedom of spirit comes from the confidence of knowing our own values, and that our community shares them.
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
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-alert";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-logo-svg";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const alerts: {
|
||||
title?: string;
|
||||
@@ -78,13 +78,13 @@ const alerts: {
|
||||
title: "Error with action",
|
||||
description: "This is a test error alert with action",
|
||||
type: "error",
|
||||
actionSlot: html`<ha-button size="small" slot="action">restart</ha-button>`,
|
||||
actionSlot: html`<ha-button size="s" slot="action">restart</ha-button>`,
|
||||
},
|
||||
{
|
||||
title: "Unsaved data",
|
||||
description: "You have unsaved data",
|
||||
type: "warning",
|
||||
actionSlot: html`<ha-button size="small" slot="action">save</ha-button>`,
|
||||
actionSlot: html`<ha-button size="s" slot="action">save</ha-button>`,
|
||||
},
|
||||
{
|
||||
title: "Slotted icon",
|
||||
@@ -135,10 +135,10 @@ const alerts: {
|
||||
export class DemoHaAlert extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-alert ${mode} demo">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
${alerts.map(
|
||||
(alert) => html`
|
||||
@@ -154,43 +154,19 @@ export class DemoHaAlert extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { mdiButtonCursor, mdiHome } from "@mdi/js";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
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-badge";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const badges: {
|
||||
type?: "badge" | "button";
|
||||
@@ -60,10 +60,10 @@ const badges: {
|
||||
export class DemoHaBadge extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-badge ${mode} demo">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
${badges.map(
|
||||
(badge) => html`
|
||||
@@ -78,45 +78,23 @@ export class DemoHaBadge extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
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;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-6);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -26,7 +26,7 @@ title: Button
|
||||
filled button
|
||||
</ha-button>
|
||||
|
||||
<ha-button size="small">
|
||||
<ha-button size="s">
|
||||
small
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ title: Button
|
||||
```html
|
||||
<ha-button> simple button </ha-button>
|
||||
|
||||
<ha-button size="small"> small </ha-button>
|
||||
<ha-button size="s"> small </ha-button>
|
||||
```
|
||||
|
||||
### API
|
||||
@@ -57,7 +57,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
|
||||
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
|
||||
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
|
||||
| size | "small"/"medium"/"large" | "medium" | Sets the button size. |
|
||||
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
|
||||
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
|
||||
| disabled | Boolean | false | Disables the button and prevents user interaction. |
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { mdiHome } from "@mdi/js";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
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 { titleCase } from "../../../../src/common/string/title-case";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const appearances = ["accent", "filled", "plain"];
|
||||
const variants = ["brand", "danger", "neutral", "warning", "success"];
|
||||
@@ -16,10 +16,10 @@ const variants = ["brand", "danger", "neutral", "warning", "success"];
|
||||
export class DemoHaButton extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-button in ${mode}">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
${variants.map(
|
||||
(variant) => html`
|
||||
@@ -49,7 +49,7 @@ export class DemoHaButton extends LitElement {
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.variant=${variant}
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
${titleCase(`${variant} ${appearance}`)}
|
||||
</ha-button>
|
||||
@@ -100,7 +100,7 @@ export class DemoHaButton extends LitElement {
|
||||
<ha-button
|
||||
.variant=${variant}
|
||||
.appearance=${appearance}
|
||||
size="small"
|
||||
size="s"
|
||||
disabled
|
||||
>
|
||||
${titleCase(`${appearance}`)}
|
||||
@@ -112,45 +112,22 @@ export class DemoHaButton extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
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;
|
||||
}
|
||||
.button {
|
||||
padding: unset;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
@@ -159,6 +136,7 @@ export class DemoHaButton extends LitElement {
|
||||
}
|
||||
.card-content div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -26,7 +26,7 @@ const chips: {
|
||||
export class DemoHaChips extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-card header="ha-chip demo">
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p>Action chip</p>
|
||||
<ha-chip-set>
|
||||
@@ -82,7 +82,7 @@ export class DemoHaChips extends LitElement {
|
||||
${chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: ""}
|
||||
: nothing}
|
||||
${chip.content}
|
||||
</ha-input-chip>
|
||||
`
|
||||
|
||||
@@ -9,9 +9,11 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-control-switch";
|
||||
import type { HaControlSwitch } from "../../../../src/components/ha-control-switch";
|
||||
import type { HASSDomTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const switches: {
|
||||
id: string;
|
||||
@@ -45,106 +47,72 @@ const switches: {
|
||||
export class DemoHaControlSwitch extends LitElement {
|
||||
@state() private checked = false;
|
||||
|
||||
handleValueChanged(e: any) {
|
||||
this.checked = e.target.checked as boolean;
|
||||
handleValueChanged(e: HASSDomTargetEvent<HaControlSwitch>) {
|
||||
this.checked = e.target.checked;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="themes">
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-control-switch ${mode}">
|
||||
${repeat(switches, (sw) => {
|
||||
const { id, label, ...config } = sw;
|
||||
return html`
|
||||
<div class="card-content">
|
||||
<label id="${mode}-${id}">${label}</label>
|
||||
<pre>Config: ${JSON.stringify(config)}</pre>
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
${repeat(switches, (sw) => {
|
||||
const { id, label, ...config } = sw;
|
||||
return html`
|
||||
<div class="card-content">
|
||||
<label id="${slot}-${id}">${label}</label>
|
||||
<pre>Config: ${JSON.stringify(config)}</pre>
|
||||
<ha-control-switch
|
||||
.checked=${this.checked}
|
||||
class=${ifDefined(config.class)}
|
||||
@change=${this.handleValueChanged}
|
||||
.pathOn=${mdiLightbulb}
|
||||
.pathOff=${mdiLightbulbOff}
|
||||
.label=${label}
|
||||
?disabled=${config.disabled}
|
||||
?reversed=${config.reversed}
|
||||
>
|
||||
</ha-control-switch>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Vertical</b></p>
|
||||
<div class="vertical-switches">
|
||||
${repeat(switches, (sw) => {
|
||||
const { label, ...config } = sw;
|
||||
return html`
|
||||
<ha-control-switch
|
||||
.checked=${this.checked}
|
||||
vertical
|
||||
class=${ifDefined(config.class)}
|
||||
@change=${this.handleValueChanged}
|
||||
.pathOn=${mdiLightbulb}
|
||||
.pathOff=${mdiLightbulbOff}
|
||||
.label=${label}
|
||||
.pathOn=${mdiGarageOpen}
|
||||
.pathOff=${mdiGarage}
|
||||
?disabled=${config.disabled}
|
||||
?reversed=${config.reversed}
|
||||
>
|
||||
</ha-control-switch>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Vertical</b></p>
|
||||
<div class="vertical-switches">
|
||||
${repeat(switches, (sw) => {
|
||||
const { label, ...config } = sw;
|
||||
return html`
|
||||
<ha-control-switch
|
||||
.checked=${this.checked}
|
||||
vertical
|
||||
class=${ifDefined(config.class)}
|
||||
@change=${this.handleValueChanged}
|
||||
.label=${label}
|
||||
.pathOn=${mdiGarageOpen}
|
||||
.pathOff=${mdiGarage}
|
||||
?disabled=${config.disabled}
|
||||
?reversed=${config.reversed}
|
||||
>
|
||||
</ha-control-switch>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
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: block;
|
||||
}
|
||||
.themes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 16px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
pre {
|
||||
margin-top: 0;
|
||||
|
||||
@@ -13,7 +13,7 @@ Our dialogs are based on the latest version of Material Design. Please note that
|
||||
|
||||
- Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead.
|
||||
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines.
|
||||
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake.
|
||||
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user **has made changes to**. Instead it will animate "no" by a little shake.
|
||||
- Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu.
|
||||
- The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom.
|
||||
- Keep the labels short, for example `Save`, `Delete`, `Enable`.
|
||||
|
||||
@@ -8,25 +8,25 @@ import {
|
||||
mdiContentPaste,
|
||||
mdiDelete,
|
||||
} from "@mdi/js";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
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-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-dropdown";
|
||||
import "../../../../src/components/ha-dropdown-item";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
@customElement("demo-components-ha-dropdown")
|
||||
export class DemoHaDropdown extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-button in ${mode}">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
<ha-dropdown>
|
||||
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
|
||||
@@ -74,45 +74,22 @@ export class DemoHaDropdown extends LitElement {
|
||||
</ha-dropdown>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
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;
|
||||
}
|
||||
.button {
|
||||
padding: unset;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
|
||||
@@ -12,7 +12,7 @@ const SMALL_TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
|
||||
export class DemoHaFaded extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-card header="ha-faded demo">
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<h3>Long text directly as slotted content</h3>
|
||||
<ha-faded>${LONG_TEXT}</ha-faded>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { mdiMagnify } from "@mdi/js";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
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";
|
||||
@@ -11,6 +10,15 @@ import "../../../../src/components/input/ha-input-copy";
|
||||
import "../../../../src/components/input/ha-input-multi";
|
||||
import "../../../../src/components/input/ha-input-search";
|
||||
import { internationalizationContext } from "../../../../src/data/context";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../../../src/data/translation";
|
||||
import type { HomeAssistantInternationalization } from "../../../../src/types";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const LOCALIZE_KEYS: Record<string, string> = {
|
||||
"ui.common.copy": "Copy",
|
||||
@@ -22,6 +30,25 @@ const LOCALIZE_KEYS: Record<string, string> = {
|
||||
"ui.common.copied_clipboard": "Copied to clipboard",
|
||||
};
|
||||
|
||||
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
|
||||
|
||||
const DEMO_I18N: HomeAssistantInternationalization = {
|
||||
localize,
|
||||
language: "en",
|
||||
selectedLanguage: null,
|
||||
locale: {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
first_weekday: FirstWeekday.language,
|
||||
time_zone: TimeZone.local,
|
||||
},
|
||||
translationMetadata: { fragments: [], translations: {} },
|
||||
loadBackendTranslation: async () => localize,
|
||||
loadFragmentTranslation: async () => localize,
|
||||
};
|
||||
|
||||
@customElement("demo-components-ha-input")
|
||||
export class DemoHaInput extends LitElement {
|
||||
constructor() {
|
||||
@@ -29,185 +56,171 @@ export class DemoHaInput extends LitElement {
|
||||
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
|
||||
new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
initialValue: {
|
||||
localize: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
|
||||
language: "en",
|
||||
selectedLanguage: null,
|
||||
locale: {} as any,
|
||||
translationMetadata: {} as any,
|
||||
loadBackendTranslation: (async () => (key: string) => key) as any,
|
||||
loadFragmentTranslation: (async () => (key: string) => key) as any,
|
||||
},
|
||||
initialValue: DEMO_I18N,
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<div slot=${slot} class="panel-content">
|
||||
<ha-card>
|
||||
<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>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>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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<ha-card header="Derivatives">
|
||||
<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>
|
||||
<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>
|
||||
`
|
||||
)}
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
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;
|
||||
}
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6);
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
@@ -224,10 +237,11 @@ export class DemoHaInput extends LitElement {
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
.row > * {
|
||||
flex: 1;
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
title: List
|
||||
---
|
||||
|
||||
# List
|
||||
|
||||
The list family provides accessible, keyboard-navigable list containers and
|
||||
item variants. Pick the container based on semantics, then the item based on
|
||||
interactivity.
|
||||
|
||||
## Containers
|
||||
|
||||
### `<ha-list-base>`
|
||||
|
||||
A styled container with roving-tabindex keyboard navigation. Host role is
|
||||
`list`. Children should be `<ha-list-item-*>`. Arrow keys rove focus;
|
||||
Home/End jump to the first/last enabled item; Enter/Space activates the
|
||||
focused item.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------ | ------- | ------- | ------------------------------ |
|
||||
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
|
||||
| `aria-label` | String | — | Accessible name. |
|
||||
|
||||
**Events**
|
||||
|
||||
- `ha-list-activated` — Enter/Space on a focused item. Detail
|
||||
`{ index: number, item: HaListItemBase }`.
|
||||
|
||||
**Methods**
|
||||
|
||||
- `focus()` — focus the active item (or the first focusable one).
|
||||
- `focusItemAtIndex(index)` — make the item at `index` active and focus it.
|
||||
- `getActiveItemIndex()` — current active index, or `-1`.
|
||||
- `setActiveItemIndex(index, focusItem?)` — move the active index without
|
||||
necessarily focusing.
|
||||
- `updateListItems()` — re-discover slotted items (called automatically on
|
||||
slotchange).
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `base` — the outer `<div role="list">`.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
- `--ha-list-gap` — spacing between items. Defaults to `0`.
|
||||
- `--ha-list-padding` — padding around the list. Defaults to `0`.
|
||||
|
||||
### `<ha-list-selectable>`
|
||||
|
||||
Selectable list. Extends `ha-list-base`. Host role is `listbox`; items must be
|
||||
`<ha-list-item-option>` (role `option`). Set `multi` for multi-select; the
|
||||
host reflects `aria-multiselectable`.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------- | ------- | ------- | -------------------------------------- |
|
||||
| `multi` | Boolean | `false` | Allow multiple options to be selected. |
|
||||
|
||||
**Events**
|
||||
|
||||
- `ha-list-item-selected` — an option was selected. Detail is the option's
|
||||
index (`number`). In single mode this is the only selection event; in multi
|
||||
mode it fires for each option added to the selection.
|
||||
- `ha-list-item-deselected` — an option was deselected (multi mode only). Detail
|
||||
is the option's index (`number`).
|
||||
|
||||
**Methods / getters**
|
||||
|
||||
- `selected` (getter) — current selection (`number` or `Set<number>`).
|
||||
- `selectedItems` (getter) — selected `HaListItemOption` elements, in index
|
||||
order.
|
||||
- `setSelected(indices)` — replace the entire selection.
|
||||
- `select(index)` — add `index` to the selection (replaces in single mode).
|
||||
- `toggle(index, force?)` — toggle a single index, or force on/off.
|
||||
- `clearSelection()` — clear all.
|
||||
|
||||
### `<ha-list-nav>`
|
||||
|
||||
Same as `ha-list-base`, but wrapped in a `<nav>` landmark
|
||||
(`<nav><div role="list">…</div></nav>`). Use `aria-label` to name the
|
||||
landmark — the value is forwarded to the inner `<nav>`. Items should be
|
||||
`<ha-list-item-button>` with an `href`.
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `nav` — the `<nav>` wrapper.
|
||||
- `base` — the inner `<div role="list">`.
|
||||
|
||||
## Items
|
||||
|
||||
All items inherit from `ha-row-item`, which provides the row layout and the
|
||||
shared slots/attributes below.
|
||||
|
||||
### Shared row layout (`ha-row-item`)
|
||||
|
||||
**Slots**
|
||||
|
||||
- `start` — leading container (icon/avatar).
|
||||
- `end` — trailing container (meta/chevron).
|
||||
- `headline` — primary text (overrides the `headline` attribute).
|
||||
- `supporting-text` — secondary text (overrides the `supporting-text` attribute).
|
||||
- `content` — escape hatch: replaces the entire middle column.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ----------------- | ------- | ------- | --------------------------------------- |
|
||||
| `headline` | String | — | Primary text. Overridden by the slot. |
|
||||
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
|
||||
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
|
||||
|
||||
**CSS parts**
|
||||
|
||||
`base`, `start`, `content`, `headline`, `supporting-text`, `end`.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
- `--ha-row-item-padding-block` — vertical padding.
|
||||
- `--ha-row-item-padding-inline` — horizontal padding.
|
||||
- `--ha-row-item-gap` — gap between `start`, `content`, and `end`.
|
||||
- `--ha-row-item-min-height` — minimum row height (default `48px`).
|
||||
|
||||
### `<ha-list-item-base>`
|
||||
|
||||
Non-interactive list row. Host role is `listitem`. Inherits everything from
|
||||
`ha-row-item`.
|
||||
|
||||
**Attributes**
|
||||
|
||||
- `interactive` (Boolean, default `false`) — opt this row into the parent
|
||||
list's roving tabindex. Useful for sortable rows that need keyboard focus
|
||||
but no click action. Interactive subclasses set this automatically.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
- `--ha-list-item-focus-radius` — focus outline border-radius.
|
||||
- `--ha-list-item-focus-width` — focus outline width (steady state).
|
||||
- `--ha-list-item-focus-width-start` — focus outline width at the start of
|
||||
the focus-in animation.
|
||||
- `--ha-list-item-focus-offset` — focus outline offset.
|
||||
- `--ha-list-item-focus-background` — background color on keyboard focus.
|
||||
|
||||
### `<ha-list-item-button>`
|
||||
|
||||
Interactive row. Renders an inner `<a>` when `href` is set, otherwise a
|
||||
`<button>`. The full row is the hit target. When placed inside a list using
|
||||
roving tabindex, the host is the tab stop and the inner element carries
|
||||
`tabindex="-1"`.
|
||||
|
||||
**Attributes**
|
||||
|
||||
- `href` (String) — when set, renders an `<a>` instead of a `<button>`.
|
||||
- `target` (String) — anchor `target` (requires `href`).
|
||||
- `rel` (String) — anchor `rel` (requires `href`).
|
||||
- `download` (String) — anchor `download` (requires `href`).
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `ripple` — the ripple effect element.
|
||||
|
||||
### `<ha-list-item-option>`
|
||||
|
||||
Selectable row. Host role is `option`; reflects `aria-selected`. Designed to
|
||||
sit inside `<ha-list-selectable>`, which owns selection state and toggles
|
||||
`selected` on this item — the option itself does not fire selection events.
|
||||
|
||||
**Attributes**
|
||||
|
||||
- `selected` (Boolean, default `false`, reflected) — set by the parent
|
||||
`ha-list-selectable`.
|
||||
- `value` (String) — value identifying the option.
|
||||
- `appearance` (`"line"` | `"checkbox"`, default `"line"`) — `"line"`
|
||||
highlights the row; `"checkbox"` renders a decorative `<ha-checkbox>`.
|
||||
- `selection-position` (`"start"` | `"end"`, default `"start"`) — side the
|
||||
checkbox sits on when `appearance="checkbox"`.
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `checkbox` — wrapper around the `<ha-checkbox>` when `appearance="checkbox"`.
|
||||
- `ripple` — the ripple effect element.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
- `--ha-list-item-selected-background` — background color when selected
|
||||
(`appearance="line"`).
|
||||
@@ -0,0 +1,455 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiChevronRight,
|
||||
mdiCog,
|
||||
mdiHome,
|
||||
mdiInformationOutline,
|
||||
mdiMapMarker,
|
||||
mdiOpenInNew,
|
||||
mdiViewDashboard,
|
||||
mdiWifi,
|
||||
} from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/components/item/ha-list-item-base";
|
||||
import "../../../../src/components/item/ha-list-item-button";
|
||||
import "../../../../src/components/item/ha-list-item-option";
|
||||
import "../../../../src/components/list/ha-list-base";
|
||||
import "../../../../src/components/list/ha-list-nav";
|
||||
import "../../../../src/components/list/ha-list-selectable";
|
||||
|
||||
type Appearance = "line" | "checkbox";
|
||||
type Position = "start" | "end";
|
||||
|
||||
const appearances: Appearance[] = ["line", "checkbox"];
|
||||
const positions: Position[] = ["start", "end"];
|
||||
const selectedStates = [false, true];
|
||||
const disabledStates = [false, true];
|
||||
|
||||
@customElement("demo-components-ha-list")
|
||||
export class DemoHaList extends LitElement {
|
||||
@state() private _buttonClicks = 0;
|
||||
|
||||
@state() private _single: number | Set<number> = -1;
|
||||
|
||||
@state() private _multiLine: number | Set<number> = new Set();
|
||||
|
||||
@state() private _multiCheckStart: number | Set<number> = new Set();
|
||||
|
||||
@state() private _multiCheckEnd: number | Set<number> = new Set();
|
||||
|
||||
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<h2>ha-list-base</h2>
|
||||
<p>
|
||||
Styled container with keyboard focus navigation. Children should be
|
||||
<code>ha-list-item-*</code>.
|
||||
</p>
|
||||
|
||||
<ha-card header="Info list (non-interactive rows)">
|
||||
<ha-list-base aria-label="Device info">
|
||||
<ha-list-item-base
|
||||
headline="IP address"
|
||||
supporting-text="192.168.1.42"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiWifi}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base headline="Location" supporting-text="Living room">
|
||||
<ha-svg-icon slot="start" .path=${mdiMapMarker}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base headline="Firmware" supporting-text="2026.4.1">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiInformationOutline}
|
||||
></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Vertical list (default)">
|
||||
<ha-list-base aria-label="Example list">
|
||||
<ha-list-item-button>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<span slot="headline">First row</span>
|
||||
<span slot="supporting-text">Supporting text</span>
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button>
|
||||
<ha-svg-icon slot="start" .path=${mdiAccount}></ha-svg-icon>
|
||||
<span slot="headline">Second row</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button disabled>
|
||||
<span slot="headline">Disabled row</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button>
|
||||
<span slot="headline">Fourth row</span>
|
||||
</ha-list-item-button>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Vertical list with wrap-focus">
|
||||
<ha-list-base wrap-focus aria-label="Wrap focus">
|
||||
<ha-list-item-button>
|
||||
<span slot="headline">A</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button>
|
||||
<span slot="headline">B</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button>
|
||||
<span slot="headline">C</span>
|
||||
</ha-list-item-button>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-item-base</h2>
|
||||
<p>Non-interactive base row with slot permutations.</p>
|
||||
|
||||
<ha-card header="Slot permutations">
|
||||
<ha-list-base aria-label="Slot permutations">
|
||||
<ha-list-item-base headline="Headline only"></ha-list-item-base>
|
||||
<ha-list-item-base
|
||||
headline="Headline"
|
||||
supporting-text="Supporting text"
|
||||
></ha-list-item-base>
|
||||
<ha-list-item-base headline="Start + headline">
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base headline="Start + headline + end">
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base
|
||||
headline="Full row"
|
||||
supporting-text="All slots filled"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base>
|
||||
<div slot="content" class="custom-content">
|
||||
<strong>Custom content escape hatch</strong>
|
||||
<span>Replaces the whole middle column</span>
|
||||
</div>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base headline="Disabled row" disabled>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-item-button</h2>
|
||||
<p>
|
||||
Interactive row. Renders an inner <code><a></code> when
|
||||
<code>href</code> is set, otherwise a <code><button></code>.
|
||||
</p>
|
||||
|
||||
<ha-card header="Button (default) / link (with href)">
|
||||
<ha-list-base aria-label="Button items">
|
||||
<ha-list-item-button @click=${this._onButtonClick}>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<span slot="headline">Button (clicks: ${this._buttonClicks})</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button
|
||||
href="https://www.home-assistant.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
<span slot="headline">Link (opens in new tab)</span>
|
||||
<span slot="supporting-text"
|
||||
>Cmd/Ctrl-click still opens in new tab</span
|
||||
>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button disabled>
|
||||
<span slot="headline">Disabled button</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button href="#nope" disabled>
|
||||
<span slot="headline">Disabled link</span>
|
||||
</ha-list-item-button>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-selectable + ha-list-item-option</h2>
|
||||
<p>
|
||||
Selectable list (<code>role="listbox"</code>). Items must be
|
||||
<code>ha-list-item-option</code>. Set <code>multi</code> for
|
||||
multi-select.
|
||||
</p>
|
||||
|
||||
<ha-card header="Single select, appearance=line">
|
||||
<ha-list-selectable
|
||||
aria-label="Single select"
|
||||
@ha-list-item-selected=${this._onSingle}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
<ha-list-item-option
|
||||
.value=${o}
|
||||
?selected=${this._isSel(this._single, i)}
|
||||
>
|
||||
<span slot="headline">${o}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>selected: ${JSON.stringify(this._toJson(this._single))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Multi select, appearance=line">
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi select line"
|
||||
@ha-list-item-selected=${this._onMultiLineSelected}
|
||||
@ha-list-item-deselected=${this._onMultiLineDeselected}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
<ha-list-item-option
|
||||
.value=${o}
|
||||
?selected=${this._isSel(this._multiLine, i)}
|
||||
>
|
||||
<span slot="headline">${o}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>selected: ${JSON.stringify(this._toJson(this._multiLine))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
header='Multi select, appearance=checkbox, selection-position="start"'
|
||||
>
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi checkbox start"
|
||||
@ha-list-item-selected=${this._onMultiCheckStartSelected}
|
||||
@ha-list-item-deselected=${this._onMultiCheckStartDeselected}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="start"
|
||||
.value=${o}
|
||||
?selected=${this._isSel(this._multiCheckStart, i)}
|
||||
>
|
||||
<span slot="headline">${o}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
|
||||
>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
header='Multi select, appearance=checkbox, selection-position="end"'
|
||||
>
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi checkbox end"
|
||||
@ha-list-item-selected=${this._onMultiCheckEndSelected}
|
||||
@ha-list-item-deselected=${this._onMultiCheckEndDeselected}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${o}
|
||||
?selected=${this._isSel(this._multiCheckEnd, i)}
|
||||
>
|
||||
<span slot="headline">${o}</span>
|
||||
<span slot="supporting-text">${o.length} characters</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
|
||||
>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Option: all combinations">
|
||||
<div class="grid">
|
||||
${appearances.map((appearance) =>
|
||||
positions.map((position) =>
|
||||
selectedStates.map((selected) =>
|
||||
disabledStates.map(
|
||||
(disabled) => html`
|
||||
<div role="listbox" class="wrap" aria-label="single option">
|
||||
<ha-list-item-option
|
||||
appearance=${appearance}
|
||||
selection-position=${position}
|
||||
?selected=${selected}
|
||||
?disabled=${disabled}
|
||||
>
|
||||
<span slot="headline"
|
||||
>${appearance} / pos=${position}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>selected=${String(selected)}
|
||||
disabled=${String(disabled)}</span
|
||||
>
|
||||
</ha-list-item-option>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-nav</h2>
|
||||
<p>
|
||||
Same as <code>ha-list-base</code> but wrapped in a
|
||||
<code><nav></code> landmark.
|
||||
</p>
|
||||
|
||||
<ha-card header="Sidebar-style navigation">
|
||||
<ha-list-nav aria-label="Primary navigation">
|
||||
${[
|
||||
{ name: "Overview", path: "#overview", icon: mdiHome },
|
||||
{ name: "Dashboards", path: "#dashboards", icon: mdiViewDashboard },
|
||||
{ name: "Map", path: "#map", icon: mdiMapMarker },
|
||||
{ name: "Settings", path: "#settings", icon: mdiCog },
|
||||
].map(
|
||||
(p) => html`
|
||||
<ha-list-item-button .href=${p.path}>
|
||||
<ha-svg-icon slot="start" .path=${p.icon}></ha-svg-icon>
|
||||
<span slot="headline">${p.name}</span>
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
</ha-list-nav>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _isSel(value: number | Set<number>, index: number): boolean {
|
||||
if (typeof value === "number") {
|
||||
return value === index;
|
||||
}
|
||||
return value.has(index);
|
||||
}
|
||||
|
||||
private _toJson(value: number | Set<number>): unknown {
|
||||
return value instanceof Set ? [...value] : value;
|
||||
}
|
||||
|
||||
private _onButtonClick = () => {
|
||||
this._buttonClicks++;
|
||||
};
|
||||
|
||||
private _withIndex(
|
||||
value: number | Set<number>,
|
||||
index: number,
|
||||
selected: boolean
|
||||
): Set<number> {
|
||||
const next = new Set(value instanceof Set ? value : []);
|
||||
if (selected) {
|
||||
next.add(index);
|
||||
} else {
|
||||
next.delete(index);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private _onSingle = (ev: CustomEvent<number>) => {
|
||||
this._single = ev.detail;
|
||||
};
|
||||
|
||||
private _onMultiLineSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiLine = this._withIndex(this._multiLine, ev.detail, true);
|
||||
};
|
||||
|
||||
private _onMultiLineDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiLine = this._withIndex(this._multiLine, ev.detail, false);
|
||||
};
|
||||
|
||||
private _onMultiCheckStartSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckStart = this._withIndex(
|
||||
this._multiCheckStart,
|
||||
ev.detail,
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
private _onMultiCheckStartDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckStart = this._withIndex(
|
||||
this._multiCheckStart,
|
||||
ev.detail,
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
private _onMultiCheckEndSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckEnd = this._withIndex(this._multiCheckEnd, ev.detail, true);
|
||||
};
|
||||
|
||||
private _onMultiCheckEndDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckEnd = this._withIndex(
|
||||
this._multiCheckEnd,
|
||||
ev.detail,
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
padding: var(--ha-space-6);
|
||||
}
|
||||
h2 {
|
||||
margin: var(--ha-space-4) 0 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-card {
|
||||
max-width: 560px;
|
||||
}
|
||||
pre {
|
||||
padding: var(--ha-space-4);
|
||||
background: var(--secondary-background-color);
|
||||
margin: 0;
|
||||
}
|
||||
.custom-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--ha-space-3);
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
.wrap {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-list": DemoHaList;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
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 type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/buttons/ha-progress-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
@customElement("demo-components-ha-progress-button")
|
||||
export class DemoHaProgressButton extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-progress-button in ${mode}">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
<ha-progress-button @click=${this._clickedSuccess}>
|
||||
Success
|
||||
@@ -59,32 +60,17 @@ export class DemoHaProgressButton extends LitElement {
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private async _clickedSuccess(ev: CustomEvent): Promise<void> {
|
||||
private _clickedSuccess(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-progress-button"]>
|
||||
) {
|
||||
console.log("Clicked success");
|
||||
const button = ev.currentTarget as any;
|
||||
const button = ev.currentTarget;
|
||||
button.progress = true;
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -93,8 +79,10 @@ export class DemoHaProgressButton extends LitElement {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private async _clickedFail(ev: CustomEvent): Promise<void> {
|
||||
const button = ev.currentTarget as any;
|
||||
private _clickedFail(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-progress-button"]>
|
||||
) {
|
||||
const button = ev.currentTarget;
|
||||
button.progress = true;
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -105,20 +93,14 @@ export class DemoHaProgressButton extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
}
|
||||
.button {
|
||||
padding: unset;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
|
||||
@@ -43,12 +43,22 @@ const fullOptions: SelectBoxOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const manyOptions: SelectBoxOption[] = [
|
||||
{ value: "opt1", label: "Option 1" },
|
||||
{ value: "opt2", label: "Option 2" },
|
||||
{ value: "opt3", label: "Option 3" },
|
||||
{ value: "opt4", label: "Option 4" },
|
||||
{ value: "opt5", label: "Option 5" },
|
||||
{ value: "opt6", label: "Option 6" },
|
||||
];
|
||||
|
||||
const selects: {
|
||||
id: string;
|
||||
label: string;
|
||||
class?: string;
|
||||
options: SelectBoxOption[];
|
||||
disabled?: boolean;
|
||||
maxColumns?: number;
|
||||
}[] = [
|
||||
{
|
||||
id: "basic",
|
||||
@@ -60,6 +70,12 @@ const selects: {
|
||||
label: "With description and image",
|
||||
options: fullOptions,
|
||||
},
|
||||
{
|
||||
id: "two-columns",
|
||||
label: "2 columns (maxColumns=2)",
|
||||
options: manyOptions,
|
||||
maxColumns: 2,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-ha-select-box")
|
||||
@@ -67,13 +83,14 @@ export class DemoHaSelectBox extends LitElement {
|
||||
@state() private value?: string = "off";
|
||||
|
||||
handleValueChanged(e: CustomEvent) {
|
||||
console.log(e.detail.value);
|
||||
this.value = e.detail.value as string;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(selects, (select) => {
|
||||
const { id, label, options } = select;
|
||||
const { id, label, options, maxColumns } = select;
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
@@ -81,6 +98,7 @@ export class DemoHaSelectBox extends LitElement {
|
||||
<ha-select-box
|
||||
.value=${this.value}
|
||||
.options=${options}
|
||||
.maxColumns=${maxColumns}
|
||||
@value-changed=${this.handleValueChanged}
|
||||
>
|
||||
</ha-select-box>
|
||||
|
||||
@@ -17,13 +17,13 @@ subtitle: A slider component for selecting a value from a range.
|
||||
### Example Usage
|
||||
|
||||
<div class="wrapper">
|
||||
<ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider>
|
||||
<ha-slider size="medium"></ha-slider>
|
||||
<ha-slider size="s" with-markers min="0" max="8" value="4"></ha-slider>
|
||||
<ha-slider size="m"></ha-slider>
|
||||
</div>
|
||||
|
||||
```html
|
||||
<ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider>
|
||||
<ha-slider size="medium"></ha-slider>
|
||||
<ha-slider size="s" with-markers min="0" max="8" value="4"></ha-slider>
|
||||
<ha-slider size="m"></ha-slider>
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-bar";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-spinner";
|
||||
import "../../../../src/components/ha-slider";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
@customElement("demo-components-ha-slider")
|
||||
export class DemoHaSlider extends LitElement {
|
||||
@@ -14,10 +14,10 @@ export class DemoHaSlider extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-slider ${mode} demo">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
<span>Default (disabled)</span>
|
||||
<ha-slider
|
||||
@@ -29,7 +29,7 @@ export class DemoHaSlider extends LitElement {
|
||||
></ha-slider>
|
||||
<span>Small</span>
|
||||
<ha-slider
|
||||
size="small"
|
||||
size="s"
|
||||
min="0"
|
||||
max="8"
|
||||
value="4"
|
||||
@@ -37,7 +37,7 @@ export class DemoHaSlider extends LitElement {
|
||||
></ha-slider>
|
||||
<span>Medium</span>
|
||||
<ha-slider
|
||||
size="medium"
|
||||
size="m"
|
||||
min="0"
|
||||
max="8"
|
||||
value="4"
|
||||
@@ -45,44 +45,19 @@ export class DemoHaSlider extends LitElement {
|
||||
></ha-slider>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
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;
|
||||
margin: 16px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-bar";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-spinner";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
@customElement("demo-components-ha-spinner")
|
||||
export class DemoHaSpinner extends LitElement {
|
||||
@@ -13,10 +13,10 @@ export class DemoHaSpinner extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-badge ${mode} demo">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-spinner size="tiny"></ha-spinner>
|
||||
@@ -27,44 +27,19 @@ export class DemoHaSpinner extends LitElement {
|
||||
<ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
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;
|
||||
margin: 16px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
@customElement("demo-components-ha-switch")
|
||||
export class DemoHaSwitch extends LitElement {
|
||||
@@ -12,10 +12,10 @@ export class DemoHaSwitch extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-switch ${mode}">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
<div class="row">
|
||||
<span>Unchecked</span>
|
||||
@@ -35,44 +35,19 @@ export class DemoHaSwitch extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
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;
|
||||
margin: 16px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-textarea";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const LONG_VALUE = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}: this content overflows the max-height and scrolls.`
|
||||
).join("\n");
|
||||
|
||||
@customElement("demo-components-ha-textarea")
|
||||
export class DemoHaTextarea extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-textarea in ${mode}">
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
<h3>Basic</h3>
|
||||
<div class="row">
|
||||
@@ -38,6 +43,11 @@ export class DemoHaTextarea extends LitElement {
|
||||
resize="auto"
|
||||
value="This textarea will grow as you type more content into it. Try adding more lines to see the effect."
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="Autogrow capped (scrolls past max-height)"
|
||||
resize="auto"
|
||||
.value=${LONG_VALUE}
|
||||
></ha-textarea>
|
||||
</div>
|
||||
|
||||
<h3>States</h3>
|
||||
@@ -84,42 +94,19 @@ export class DemoHaTextarea extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
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;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../../../../src/components/ha-tip";
|
||||
import { provide } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../../../src/components/ha-tip";
|
||||
import { internationalizationContext } from "../../../../src/data/context";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../../../src/data/translation";
|
||||
import type { HomeAssistantInternationalization } from "../../../../src/types";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const tips: (string | TemplateResult)[] = [
|
||||
"Test tip",
|
||||
@@ -12,59 +21,57 @@ const tips: (string | TemplateResult)[] = [
|
||||
html`<i>Tip</i> <b>with</b> <sub>HTML</sub>`,
|
||||
];
|
||||
|
||||
const localize = (key: string) => key;
|
||||
|
||||
const DEMO_I18N: HomeAssistantInternationalization = {
|
||||
localize,
|
||||
language: "en",
|
||||
selectedLanguage: null,
|
||||
locale: {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
first_weekday: FirstWeekday.language,
|
||||
time_zone: TimeZone.local,
|
||||
},
|
||||
translationMetadata: { fragments: [], translations: {} },
|
||||
loadBackendTranslation: async () => localize,
|
||||
loadFragmentTranslation: async () => localize,
|
||||
};
|
||||
|
||||
@customElement("demo-components-ha-tip")
|
||||
export class DemoHaTip extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html` ${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-tip ${mode} demo">
|
||||
<div class="card-content">
|
||||
${tips.map(
|
||||
(tip) =>
|
||||
html`<ha-tip .hass=${provideHass(this)}>${tip}</ha-tip>`
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}`;
|
||||
}
|
||||
@provide({ context: internationalizationContext })
|
||||
@state()
|
||||
protected _i18n = DEMO_I18N;
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<ha-card slot=${slot}>
|
||||
<div class="card-content">
|
||||
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
}
|
||||
ha-tip {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -4,21 +4,30 @@ title: Home
|
||||
|
||||
# Welcome to Home Assistant Design
|
||||
|
||||
This portal aims to aid designers and developers on improving the Home Assistant interface. It consists of working code, resources and guidelines.
|
||||
This is the design gallery for the Home Assistant frontend: a living reference of working components, dashboard cards, and brand and copy guidance. Every page runs outside a Home Assistant instance, so you can explore the interface, try components in isolation, and review changes against a consistent baseline.
|
||||
|
||||
## Home Assistant interface
|
||||
## Browse the gallery
|
||||
|
||||
The Home Assistant frontend allows users to browse and control the state of their home, manage their automations and configure integrations. The frontend is designed as a mobile-first experience. It is a progressive web application and offers an app-like experience to our users. The Home Assistant frontend needs to be fast. But it also needs to work on a wide range of old devices.
|
||||
- [Brand](#brand/logo): the logo, personality, and the story behind the Open Home.
|
||||
- [Components](#components/ha-button): the `ha-*` component library with live demos and API notes.
|
||||
- [Dashboards](#lovelace/introduction): Lovelace cards rendered from real card configuration.
|
||||
- [More Info dialogs](#more-info/light): the more-info experience for each entity type.
|
||||
- [Automation](#automation/editor-trigger): trigger, condition, and action editors, plus trace views.
|
||||
- [Users](#user-test/user-types): the audiences we design for.
|
||||
- [Date and time](#date-time/date): date and time formatting examples.
|
||||
- [Miscellaneous](#misc/entity-state): smaller utilities and patterns, plus how to edit this gallery.
|
||||
|
||||
### Material Design
|
||||
## Testing and playground
|
||||
|
||||
The Home Assistant interface is based on Material Design. It's a design system created by Google to quickly build high-quality digital experiences. Components and guidelines that are custom made for Home Assistant are documented on this portal. For all other components check <a href="https://material.io" rel="noopener noreferrer" target="_blank">material.io</a>.
|
||||
Every page runs against fake state, so you can interact with components safely and reproducibly. Treat the demo pages as a playground: change a value, resize the window, or switch the layout to right-to-left to check spacing and direction. Use the gallery to reproduce a UI state in isolation before debugging it in a full Home Assistant setup.
|
||||
|
||||
Open **Settings** from the gear icon in the sidebar to switch between light and dark themes or preview the interface in right-to-left.
|
||||
|
||||
## Designers
|
||||
|
||||
We want to make it as easy for designers to contribute as it is for developers. There’s a lot a designer can contribute to:
|
||||
We want to make it as easy for designers to contribute as it is for developers. There's a lot a designer can contribute to:
|
||||
|
||||
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
|
||||
- Meet us in the <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
|
||||
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
|
||||
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const SHADOWS = ["s", "m", "l"] as const;
|
||||
|
||||
@@ -9,67 +8,32 @@ const SHADOWS = ["s", "m", "l"] as const;
|
||||
export class DemoMiscBoxShadow extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<h2>${mode}</h2>
|
||||
<div class="grid">
|
||||
${SHADOWS.map(
|
||||
(size) => html`
|
||||
<div
|
||||
class="box"
|
||||
style="box-shadow: var(--ha-box-shadow-${size})"
|
||||
>
|
||||
${size}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<div slot=${slot} class="panel-content">
|
||||
<div class="grid">
|
||||
${SHADOWS.map(
|
||||
(size) => html`
|
||||
<div
|
||||
class="box"
|
||||
style="box-shadow: var(--ha-box-shadow-${size})"
|
||||
>
|
||||
${size}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 48px;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.light,
|
||||
.dark {
|
||||
flex: 1;
|
||||
background-color: var(--primary-background-color);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-text-color);
|
||||
text-transform: capitalize;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid {
|
||||
|
||||
@@ -10,7 +10,7 @@ All pages are stored in [the pages folder][pages-folder] on GitHub. Pages are gr
|
||||
|
||||
## Development
|
||||
|
||||
You can develop design.home-assistant.io locally by checking out [the Home Assistant frontend repository](https://github.com/home-assistant/frontend). The command to run the gallery is `gallery/script/develop_gallery`. It will automatically open a browser window and load the development version of the website.
|
||||
You can develop design.home-assistant.io locally by checking out [the Home Assistant frontend repository](https://github.com/home-assistant/frontend). The command to run the gallery is `gallery/script/develop_gallery`. After the first build finishes, the command prints the local URL for the development version of the website.
|
||||
|
||||
## Creating a page
|
||||
|
||||
@@ -13,6 +13,28 @@ export interface NetworkInfo {
|
||||
supervisor_internet: boolean;
|
||||
}
|
||||
|
||||
interface SupervisorJob {
|
||||
name: string;
|
||||
reference: string | null;
|
||||
uuid: string;
|
||||
progress: number; // float, 0–100
|
||||
stage: string | null;
|
||||
done: boolean;
|
||||
errors: {
|
||||
type: string;
|
||||
message: string;
|
||||
stage: string | null;
|
||||
}[];
|
||||
created: string; // ISO datetime string
|
||||
extra: Record<string, unknown> | null;
|
||||
child_jobs: SupervisorJob[];
|
||||
}
|
||||
|
||||
export interface SupervisorJobInfo {
|
||||
ignore_conditions: string[];
|
||||
jobs: SupervisorJob[];
|
||||
}
|
||||
|
||||
export const ALTERNATIVE_DNS_SERVERS: {
|
||||
ipv4: string[];
|
||||
ipv6: string[];
|
||||
@@ -57,6 +79,15 @@ export async function getSupervisorNetworkInfo(): Promise<NetworkInfo> {
|
||||
return responseData?.data;
|
||||
}
|
||||
|
||||
export async function getSupervisorJobsInfo(): Promise<
|
||||
HassioResponse<SupervisorJobInfo>
|
||||
> {
|
||||
const responseData = await handleFetchPromise<
|
||||
HassioResponse<SupervisorJobInfo>
|
||||
>(fetch("/supervisor-api/jobs/info"));
|
||||
return responseData;
|
||||
}
|
||||
|
||||
export const setSupervisorNetworkDns = async (
|
||||
dnsServerIndex: number,
|
||||
primaryInterface: string
|
||||
|
||||
@@ -2,9 +2,9 @@ import { mdiOpenInNew } from "@mdi/js";
|
||||
import { css, html, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { extractSearchParam } from "../../src/common/url/search-params";
|
||||
import "../../src/components/animation/ha-fade-in";
|
||||
import "../../src/components/ha-alert";
|
||||
import "../../src/components/ha-button";
|
||||
import "../../src/components/animation/ha-fade-in";
|
||||
import "../../src/components/ha-spinner";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/progress/ha-progress-bar";
|
||||
@@ -15,6 +15,7 @@ import { haStyle } from "../../src/resources/styles";
|
||||
import "./components/landing-page-logs";
|
||||
import "./components/landing-page-network";
|
||||
import {
|
||||
getSupervisorJobsInfo,
|
||||
getSupervisorNetworkInfo,
|
||||
pingSupervisor,
|
||||
type NetworkInfo,
|
||||
@@ -24,6 +25,7 @@ import { LandingPageBaseElement } from "./landing-page-base-element";
|
||||
export const ASSUME_CORE_START_SECONDS = 60;
|
||||
const SCHEDULE_CORE_CHECK_SECONDS = 1;
|
||||
const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
|
||||
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
|
||||
|
||||
@customElement("ha-landing-page")
|
||||
class HaLandingPage extends LandingPageBaseElement {
|
||||
@@ -39,6 +41,8 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
|
||||
@state() private _coreCheckActive = false;
|
||||
|
||||
@state() private _progress = -1;
|
||||
|
||||
private _mobileApp =
|
||||
extractSearchParam("redirect_uri") === "homeassistant://auth-callback";
|
||||
|
||||
@@ -60,7 +64,14 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
${!networkIssue && !this._supervisorError
|
||||
? html`
|
||||
<p>${this.localize("subheader")}</p>
|
||||
<ha-progress-bar indeterminate></ha-progress-bar>
|
||||
<ha-progress-bar
|
||||
.indeterminate=${this._progress <= 0}
|
||||
.value=${this._progress > 0 ? this._progress : undefined}
|
||||
.loading=${this._progress >= 0}
|
||||
>${this._progress > 0
|
||||
? `${Math.round(this._progress)}%`
|
||||
: nothing}</ha-progress-bar
|
||||
>
|
||||
`
|
||||
: nothing}
|
||||
${networkIssue || this._networkInfoError
|
||||
@@ -126,6 +137,7 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
import("../../src/components/ha-language-picker");
|
||||
|
||||
this._fetchSupervisorInfo(true);
|
||||
this._fetchSupervisorJobsInfo();
|
||||
}
|
||||
|
||||
private _scheduleFetchSupervisorInfo() {
|
||||
@@ -138,6 +150,13 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _scheduleFetchSupervisorJobsInfo() {
|
||||
setTimeout(
|
||||
() => this._fetchSupervisorJobsInfo(),
|
||||
SCHEDULE_FETCH_JOBS_INFO_SECONDS * 1000
|
||||
);
|
||||
}
|
||||
|
||||
private _scheduleTurnOffCoreCheck() {
|
||||
setTimeout(() => {
|
||||
this._coreCheckActive = false;
|
||||
@@ -165,7 +184,7 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
// assume supervisor update if ping fails -> don't show an error
|
||||
if (!this._coreCheckActive && err.message !== "ping-failed") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
console.error("Failed to fetch supervisor info", err);
|
||||
this._networkInfoError = true;
|
||||
}
|
||||
}
|
||||
@@ -175,6 +194,33 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchSupervisorJobsInfo() {
|
||||
try {
|
||||
const jobsInfo = await getSupervisorJobsInfo();
|
||||
const coreInstallJob =
|
||||
jobsInfo.result === "ok"
|
||||
? jobsInfo.data.jobs.find(
|
||||
(job) => job.name === "home_assistant_core_install"
|
||||
)
|
||||
: undefined;
|
||||
if (coreInstallJob) {
|
||||
this._progress = coreInstallJob.progress;
|
||||
} else {
|
||||
this._progress = -1;
|
||||
}
|
||||
} catch (err: any) {
|
||||
await this._checkCoreAvailability();
|
||||
|
||||
if (!this._coreCheckActive) {
|
||||
this._progress = -1;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to fetch supervisor jobs info", err);
|
||||
}
|
||||
}
|
||||
|
||||
this._scheduleFetchSupervisorJobsInfo();
|
||||
}
|
||||
|
||||
private async _checkCoreAvailability() {
|
||||
try {
|
||||
const response = await fetch("/manifest.json");
|
||||
@@ -222,21 +268,27 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
ha-language-picker {
|
||||
min-width: 200px;
|
||||
}
|
||||
ha-alert p {
|
||||
text-align: unset;
|
||||
}
|
||||
.footer ha-svg-icon {
|
||||
--mdc-icon-size: var(--ha-space-5);
|
||||
}
|
||||
ha-language-picker {
|
||||
margin-inline-start: calc(-1 * var(--ha-space-4));
|
||||
}
|
||||
ha-button {
|
||||
margin-inline-end: calc(-1 * var(--ha-space-2));
|
||||
}
|
||||
ha-fade-in {
|
||||
min-height: calc(100vh - 64px - 88px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
ha-progress-bar {
|
||||
--ha-progress-bar-track-height: 20px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
[build.environment]
|
||||
YARN_VERSION = "1.22.11"
|
||||
NODE_OPTIONS = "--max_old_space_size=6144"
|
||||
|
||||
@@ -14,100 +14,96 @@
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "tsc",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
|
||||
"format": "yarn run format:eslint && yarn run format:prettier",
|
||||
"postinstall": "husky",
|
||||
"prepack": "pinst --disable",
|
||||
"postpack": "pinst --enable",
|
||||
"test": "vitest run --config test/vitest.config.ts",
|
||||
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.2",
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.1",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@codemirror/view": "6.43.1",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.0",
|
||||
"@formatjs/intl-displaynames": "7.3.3",
|
||||
"@formatjs/intl-durationformat": "0.10.5",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.4",
|
||||
"@formatjs/intl-listformat": "8.3.3",
|
||||
"@formatjs/intl-locale": "5.3.3",
|
||||
"@formatjs/intl-numberformat": "9.3.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.3",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.3",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
"@formatjs/intl-displaynames": "7.3.10",
|
||||
"@formatjs/intl-durationformat": "0.10.14",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
"@formatjs/intl-listformat": "8.3.10",
|
||||
"@formatjs/intl-locale": "5.3.9",
|
||||
"@formatjs/intl-numberformat": "9.3.11",
|
||||
"@formatjs/intl-pluralrules": "6.3.10",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.10",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/luxon3": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@home-assistant/webawesome": "3.3.1-ha.1",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
|
||||
"@material/mwc-base": "0.27.0",
|
||||
"@material/mwc-drawer": "0.27.0",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/mwc-radio": "0.27.0",
|
||||
"@material/mwc-top-app-bar": "0.27.0",
|
||||
"@material/mwc-top-app-bar-fixed": "0.27.0",
|
||||
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
|
||||
"@material/web": "2.4.1",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.21",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "3.9.1",
|
||||
"@tsparticles/preset-links": "3.2.0",
|
||||
"@tsparticles/engine": "4.1.3",
|
||||
"@tsparticles/preset-links": "4.1.3",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.1.2",
|
||||
"barcode-detector": "3.2.0",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.0.0",
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.3.0",
|
||||
"fuse.js": "7.4.2",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.2",
|
||||
"js-yaml": "4.1.1",
|
||||
"idb-keyval": "6.2.5",
|
||||
"intl-messageformat": "11.2.8",
|
||||
"js-yaml": "4.2.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.2",
|
||||
"marked": "18.0.5",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -119,32 +115,31 @@
|
||||
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "3.0.0",
|
||||
"tinykeys": "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-expiration": "7.4.0",
|
||||
"workbox-precaching": "7.4.0",
|
||||
"workbox-routing": "7.4.0",
|
||||
"workbox-strategies": "7.4.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-expiration": "7.4.1",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"xss": "1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/core": "7.29.7",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.8",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@babel/plugin-transform-runtime": "7.29.7",
|
||||
"@babel/preset-env": "7.29.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.59.0",
|
||||
"@lokalise/node-api": "15.7.1",
|
||||
"@html-eslint/eslint-plugin": "0.62.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.9",
|
||||
"@rspack/core": "2.0.0",
|
||||
"@rspack/dev-server": "2.0.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@rsdoctor/rspack-plugin": "1.5.13",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@rspack/dev-server": "2.0.3",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
@@ -156,70 +151,70 @@
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/mocha": "10.0.10",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint": "10.5.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-lit": "2.2.1",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
"fs-extra": "11.3.5",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.5.0",
|
||||
"globals": "17.6.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.0.2",
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.0.7",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.8.3",
|
||||
"rspack-manifest-plugin": "5.2.1",
|
||||
"prettier": "3.8.4",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "21.1.2",
|
||||
"tar": "7.5.13",
|
||||
"terser-webpack-plugin": "5.5.0",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.16",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.0",
|
||||
"typescript-eslint": "8.61.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.5",
|
||||
"vitest": "4.1.8",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
},
|
||||
"resolutions": {
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"globals": "17.5.0",
|
||||
"globals": "17.6.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.14.1",
|
||||
"packageManager": "yarn@4.16.0",
|
||||
"volta": {
|
||||
"node": "24.15.0"
|
||||
"node": "24.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260429.4"
|
||||
version = "20260527.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -18,7 +18,46 @@
|
||||
"enabled": true,
|
||||
"schedule": ["on the 19th day of the month before 4am"]
|
||||
},
|
||||
"customDatasources": {
|
||||
"ha-core-python": {
|
||||
"defaultRegistryUrlTemplate": "https://raw.githubusercontent.com/home-assistant/core/dev/.python-version",
|
||||
"format": "plain"
|
||||
}
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"description": "Keep PYTHON_VERSION in sync with home-assistant/core (patch + minor)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/[^/]+\\.ya?ml$/"],
|
||||
"matchStrings": ["PYTHON_VERSION: \"(?<currentValue>[^\"]+)\""],
|
||||
"depNameTemplate": "python",
|
||||
"datasourceTemplate": "custom.ha-core-python",
|
||||
"versioningTemplate": "python"
|
||||
},
|
||||
{
|
||||
"description": "Keep devcontainer image and requires-python in sync with home-assistant/core (minor only)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.devcontainer/Dockerfile$/",
|
||||
"/^pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"devcontainers/python:(?<currentValue>[\\d.]+)",
|
||||
"requires-python = \">=(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "python",
|
||||
"datasourceTemplate": "custom.ha-core-python",
|
||||
"versioningTemplate": "python",
|
||||
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group all Python version updates from home-assistant/core",
|
||||
"matchDepNames": ["python"],
|
||||
"matchDatasources": ["custom.ha-core-python"],
|
||||
"groupName": "Python version"
|
||||
},
|
||||
{
|
||||
"description": "MDC packages are pinned to the same version as MWC",
|
||||
"extends": ["monorepo:material-components-web"],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
// Checks that all production dependencies use approved open-source licenses.
|
||||
//
|
||||
// To allow a new license type, add its SPDX identifier to ALLOWED_LICENSES.
|
||||
// To allow a specific package that cannot be relicensed (e.g. a dual-license
|
||||
// package where the reported identifier is non-standard), add it to
|
||||
// ALLOWED_PACKAGES with a comment explaining why.
|
||||
|
||||
import { createRequire } from "module";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const checker = require("license-checker-rseidelsohn");
|
||||
const root = path.resolve(fileURLToPath(import.meta.url), "../../");
|
||||
|
||||
// Permissive licenses that are compatible with distribution in a compiled wheel.
|
||||
// Copyleft licenses (GPL, LGPL, AGPL, EUPL, etc.) must NOT be added here.
|
||||
const ALLOWED_LICENSES = new Set([
|
||||
"MIT",
|
||||
"MIT*",
|
||||
"ISC",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSD*",
|
||||
"Apache-2.0",
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
|
||||
"Unlicense",
|
||||
"CC-BY-4.0",
|
||||
]);
|
||||
|
||||
// Packages whose license identifier is ambiguous or non-standard but have been
|
||||
// manually verified as permissive. Add only when strictly necessary.
|
||||
const ALLOWED_PACKAGES = {
|
||||
// No entries currently needed.
|
||||
};
|
||||
|
||||
checker.init(
|
||||
{
|
||||
start: root,
|
||||
production: true,
|
||||
excludePrivatePackages: true,
|
||||
},
|
||||
(err, packages) => {
|
||||
if (err) {
|
||||
console.error("license-checker failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const [nameAtVersion, info] of Object.entries(packages)) {
|
||||
if (nameAtVersion in ALLOWED_PACKAGES) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const license = info.licenses;
|
||||
|
||||
if (!ALLOWED_LICENSES.has(license)) {
|
||||
violations.push({ package: nameAtVersion, license });
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(
|
||||
"The following packages have licenses that are not on the allowlist:"
|
||||
);
|
||||
for (const { package: pkg, license } of violations) {
|
||||
console.error(` ${pkg}: ${license}`);
|
||||
}
|
||||
console.error(`
|
||||
If the license is permissive and appropriate for distribution, add it
|
||||
to ALLOWED_LICENSES in script/check-licenses. If it is a specific
|
||||
package with an ambiguous identifier, add it to ALLOWED_PACKAGES.
|
||||
|
||||
Do NOT add copyleft licenses (GPL, LGPL, AGPL, etc.) to the allowlist.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const count = Object.keys(packages).length;
|
||||
console.log(
|
||||
`License check passed: all ${count} production dependencies use approved licenses.`
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -54,6 +54,8 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
@query("ha-auth-form") private _form?: HaAuthForm;
|
||||
|
||||
@query("ha-form") private _haForm?: HTMLElement;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -160,9 +162,8 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
// 100ms to give all the form elements time to initialize.
|
||||
setTimeout(() => {
|
||||
const form = this.renderRoot.querySelector("ha-form");
|
||||
if (form) {
|
||||
(form as any).focus();
|
||||
if (this._haForm) {
|
||||
(this._haForm as any).focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @param arr - The array to get combinations of
|
||||
* @returns A multidimensional array of all possible combinations
|
||||
*/
|
||||
export function getAllCombinations<T>(arr: T[]) {
|
||||
export function getAllCombinations<T>(arr: readonly T[]): T[][] {
|
||||
return arr.reduce<T[][]>(
|
||||
(combinations, element) =>
|
||||
combinations.concat(
|
||||
|
||||
@@ -4,9 +4,7 @@ import { ensureArray } from "../array/ensure-array";
|
||||
import { isComponentLoaded } from "./is_component_loaded";
|
||||
|
||||
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
(isCore(page) || isLoadedIntegration(hass, page)) &&
|
||||
!hideAdvancedPage(hass, page) &&
|
||||
isNotLoadedIntegration(hass, page);
|
||||
isCore(page) || isLoadedIntegration(hass, page);
|
||||
|
||||
export const isLoadedIntegration = (
|
||||
hass: HomeAssistant,
|
||||
@@ -17,18 +15,4 @@ export const isLoadedIntegration = (
|
||||
isComponentLoaded(hass.config, integration)
|
||||
);
|
||||
|
||||
export const isNotLoadedIntegration = (
|
||||
hass: HomeAssistant,
|
||||
page: PageNavigation
|
||||
) =>
|
||||
!page.not_component ||
|
||||
!ensureArray(page.not_component).some((integration) =>
|
||||
isComponentLoaded(hass.config, integration)
|
||||
);
|
||||
|
||||
export const isCore = (page: PageNavigation) => page.core;
|
||||
export const isAdvancedPage = (page: PageNavigation) => page.advancedOnly;
|
||||
export const userWantsAdvanced = (hass: HomeAssistant) =>
|
||||
hass.userData?.showAdvanced;
|
||||
export const hideAdvancedPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
isAdvancedPage(page) && !userWantsAdvanced(hass);
|
||||
|
||||
@@ -114,6 +114,15 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
|
||||
export const UNIT_C = "°C";
|
||||
export const UNIT_F = "°F";
|
||||
|
||||
/** Length units. */
|
||||
export const UNIT_IN = "in";
|
||||
export const UNIT_KM = "km";
|
||||
export const UNIT_MM = "mm";
|
||||
|
||||
/** Pressure units. */
|
||||
export const UNIT_HPA = "hPa";
|
||||
export const UNIT_INHG = "inHg";
|
||||
|
||||
/** Entity ID of the default view. */
|
||||
export const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { setupConditionListeners } from "../condition/listeners";
|
||||
|
||||
/**
|
||||
* Reactive controller that manages the media-query and time-based listeners
|
||||
* needed to keep a set of lovelace visibility conditions evaluated live.
|
||||
*
|
||||
* The host is responsible for the actual evaluation (e.g. computing visible /
|
||||
* hidden / invalid state); the controller only triggers it via the supplied
|
||||
* `onUpdate` callback when something the conditions depend on changes. Call
|
||||
* `setup()` whenever the conditions change; the controller clears previous
|
||||
* listeners and re-subscribes. Listeners are automatically released when the
|
||||
* host disconnects.
|
||||
*/
|
||||
export class ConditionListenersController implements ReactiveController {
|
||||
private _unsubs: (() => void)[] = [];
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
public setup(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
onUpdate: () => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this.clear();
|
||||
if (!conditions.length) {
|
||||
return;
|
||||
}
|
||||
setupConditionListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => this._unsubs.push(unsub),
|
||||
() => onUpdate(),
|
||||
getContext
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import timezones from "google-timezones-json";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
|
||||
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
|
||||
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
|
||||
// instead of an IANA zone name. Only accept values that are known IANA zones,
|
||||
// matching the list used by ha-timezone-picker.
|
||||
const RESOLVED_TIME_ZONE =
|
||||
RESOLVED_RAW &&
|
||||
(RESOLVED_RAW === "UTC" ||
|
||||
RESOLVED_RAW === "Etc/UTC" ||
|
||||
RESOLVED_RAW in timezones)
|
||||
? RESOLVED_RAW
|
||||
: undefined;
|
||||
|
||||
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
|
||||
|
||||
// Browser time zone can be determined from Intl, with fallback to UTC for polyfill or no support.
|
||||
export const LOCAL_TIME_ZONE = RESOLVED_TIME_ZONE ?? "UTC";
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { entitiesContext, statesContext } from "../../data/context";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../types";
|
||||
import {
|
||||
entitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../data/context";
|
||||
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
|
||||
import type { LocalizeFunc } from "../translations/localize";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import { transform } from "./transform";
|
||||
|
||||
interface ConsumeEntryConfig {
|
||||
@@ -18,6 +27,28 @@ const resolveAtPath = (host: unknown, path: readonly string[]) => {
|
||||
return cur;
|
||||
};
|
||||
|
||||
/** Reuse `previous` when every entry still references the same `HassEntity`. */
|
||||
export const preserveUnchangedEntityStatesRecord = <
|
||||
T extends Record<string, HassEntity | undefined>,
|
||||
>(
|
||||
previous: T | undefined,
|
||||
next: T
|
||||
): T => {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
const nextKeys = Object.keys(next);
|
||||
if (Object.keys(previous).length !== nextKeys.length) {
|
||||
return next;
|
||||
}
|
||||
for (const key of nextKeys) {
|
||||
if (previous[key] !== next[key]) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return previous;
|
||||
};
|
||||
|
||||
const composeDecorator = <T, V>(
|
||||
context: Parameters<typeof consume>[0]["context"],
|
||||
watchKey: string | undefined,
|
||||
@@ -55,27 +86,52 @@ export const consumeEntityState = (config: ConsumeEntryConfig) =>
|
||||
);
|
||||
|
||||
/**
|
||||
* Like {@link consumeEntityState} but for an array of entity IDs at
|
||||
* `entityIdPath`. Resolves to a `HassEntity[]` containing one entry per
|
||||
* currently-available entity (missing entities and non-string IDs are
|
||||
* filtered out; original order is preserved).
|
||||
* Like {@link consumeEntityState} but for one or more entity IDs at
|
||||
* `entityIdPath` (a string or string array; wrapped with {@link ensureArray}).
|
||||
* Resolves to a record keyed by entity ID containing the currently-available
|
||||
* entities (missing entities and non-string IDs are filtered out). Returns the
|
||||
* previous record when none of the selected entities changed.
|
||||
*/
|
||||
export const consumeEntityStates = (config: ConsumeEntryConfig) =>
|
||||
composeDecorator<HassEntities, HassEntity[]>(
|
||||
statesContext,
|
||||
config.entityIdPath[0],
|
||||
function (states) {
|
||||
const ids = resolveAtPath(this, config.entityIdPath);
|
||||
if (!Array.isArray(ids) || !states) return undefined;
|
||||
const result: HassEntity[] = [];
|
||||
for (const id of ids) {
|
||||
if (typeof id !== "string") continue;
|
||||
const state = states[id];
|
||||
if (state !== undefined) result.push(state);
|
||||
}
|
||||
return result;
|
||||
export const consumeEntityStates = (config: ConsumeEntryConfig) => {
|
||||
const watchKey = config.entityIdPath[0];
|
||||
const buildRecord = function (this: unknown, states: HassEntities) {
|
||||
const ids = ensureArray(resolveAtPath(this, config.entityIdPath));
|
||||
if (!ids || !states) return undefined;
|
||||
const result: Record<string, HassEntity> = {};
|
||||
for (const id of ids) {
|
||||
if (typeof id !== "string") continue;
|
||||
const state = states[id];
|
||||
if (state !== undefined) result[id] = state;
|
||||
}
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
return (proto: unknown, propertyKey: string) => {
|
||||
const key = String(propertyKey);
|
||||
const transformDec = transform<
|
||||
HassEntities,
|
||||
Record<string, HassEntity> | undefined
|
||||
>({
|
||||
transformer: function (this: unknown, states: HassEntities) {
|
||||
const next = buildRecord.call(this, states);
|
||||
if (next === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const previous = (this as Record<string, unknown>)[
|
||||
`__transform_${key}`
|
||||
] as Record<string, HassEntity> | undefined;
|
||||
return preserveUnchangedEntityStatesRecord(previous, next);
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({
|
||||
context: statesContext,
|
||||
subscribe: true,
|
||||
});
|
||||
transformDec(proto as never, propertyKey);
|
||||
consumeDec(proto as never, propertyKey);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Consumes `entitiesContext` and narrows it to the
|
||||
@@ -91,3 +147,15 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
|
||||
return typeof id === "string" ? entities?.[id] : undefined;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Consumes `internationalizationContext` and narrows it to the `localize`
|
||||
* function. No host watching is needed — the decorated property updates
|
||||
* whenever the i18n context changes.
|
||||
*/
|
||||
export const consumeLocalize = () =>
|
||||
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
|
||||
internationalizationContext,
|
||||
undefined,
|
||||
({ localize }) => localize
|
||||
);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Walks up the composed tree (jumping shadow roots → their hosts), returning
|
||||
* the ancestor chain top-down. Used to compare two nodes that may live in
|
||||
* different shadow trees — `Node.compareDocumentPosition` only works within a
|
||||
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
|
||||
*/
|
||||
const composedAncestorPath = (node: Node): Node[] => {
|
||||
const path: Node[] = [];
|
||||
let cur: Node | null = node;
|
||||
while (cur) {
|
||||
path.push(cur);
|
||||
const parent = cur.parentNode;
|
||||
if (parent instanceof ShadowRoot) {
|
||||
cur = parent.host;
|
||||
} else if (parent) {
|
||||
cur = parent;
|
||||
} else {
|
||||
const root = cur.getRootNode();
|
||||
cur = root instanceof ShadowRoot ? root.host : null;
|
||||
}
|
||||
}
|
||||
return path.reverse();
|
||||
};
|
||||
|
||||
/**
|
||||
* Document-order comparator that works across shadow boundaries. Suitable as
|
||||
* the `Array.prototype.sort` callback for collections of nodes that may live
|
||||
* in different shadow trees.
|
||||
*/
|
||||
export const compareNodeOrder = (a: Node, b: Node): number => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
const pa = composedAncestorPath(a);
|
||||
const pb = composedAncestorPath(b);
|
||||
let i = 0;
|
||||
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
|
||||
i++;
|
||||
}
|
||||
if (i === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (i === pa.length) {
|
||||
return -1;
|
||||
}
|
||||
if (i === pb.length) {
|
||||
return 1;
|
||||
}
|
||||
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
? -1
|
||||
: 1;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Load a resource and get a promise when loading done.
|
||||
// From: https://davidwalsh.name/javascript-loader
|
||||
|
||||
const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
|
||||
const _load = (tag: "link" | "script", url: string, type?: "module") =>
|
||||
// This promise will be used by Promise.all to determine success or failure
|
||||
new Promise((resolve, reject) => {
|
||||
const element = document.createElement(tag);
|
||||
@@ -33,5 +33,4 @@ const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
|
||||
});
|
||||
export const loadCSS = (url: string) => _load("link", url);
|
||||
export const loadJS = (url: string) => _load("script", url);
|
||||
export const loadImg = (url: string) => _load("img", url);
|
||||
export const loadModule = (url: string) => _load("script", url, "module");
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Scroll to a specific y coordinate.
|
||||
*
|
||||
* Copied from paper-scroll-header-panel.
|
||||
*
|
||||
* @method scroll
|
||||
* @param {number} top The coordinate to scroll to, along the y-axis.
|
||||
* @param {boolean} smooth true if the scroll position should be smoothly adjusted.
|
||||
*/
|
||||
export default function scrollToTarget(element, target) {
|
||||
// the scroll event will trigger _updateScrollState directly,
|
||||
// However, _updateScrollState relies on the previous `scrollTop` to update the states.
|
||||
// Calling _updateScrollState will ensure that the states are synced correctly.
|
||||
const top = 0;
|
||||
const scroller = target;
|
||||
const easingFn = function easeOutQuad(t, b, c, d) {
|
||||
t /= d;
|
||||
return -c * t * (t - 2) + b;
|
||||
};
|
||||
const animationId = Math.random();
|
||||
const duration = 200;
|
||||
const startTime = Date.now();
|
||||
const currentScrollTop = scroller.scrollTop;
|
||||
const deltaScrollTop = top - currentScrollTop;
|
||||
element._currentAnimationId = animationId;
|
||||
(function updateFrame() {
|
||||
const now = Date.now();
|
||||
const elapsedTime = now - startTime;
|
||||
if (elapsedTime > duration) {
|
||||
scroller.scrollTop = top;
|
||||
} else if (element._currentAnimationId === animationId) {
|
||||
scroller.scrollTop = easingFn(
|
||||
elapsedTime,
|
||||
currentScrollTop,
|
||||
deltaScrollTop,
|
||||
duration
|
||||
);
|
||||
requestAnimationFrame(updateFrame.bind(element));
|
||||
}
|
||||
}).call(element);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import type { Map, TileLayer } from "leaflet";
|
||||
// Sets up a Leaflet map on the provided DOM element
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletModuleType = typeof import("leaflet");
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletDrawModuleType = typeof import("leaflet-draw");
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement,
|
||||
@@ -45,17 +43,6 @@ export const setupLeafletMap = async (
|
||||
return [map, Leaflet, tileLayer];
|
||||
};
|
||||
|
||||
export const replaceTileLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: Map,
|
||||
tileLayer: TileLayer
|
||||
): TileLayer => {
|
||||
map.removeLayer(tileLayer);
|
||||
tileLayer = createTileLayer(leaflet);
|
||||
tileLayer.addTo(map);
|
||||
return tileLayer;
|
||||
};
|
||||
|
||||
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
|
||||
leaflet.tileLayer(
|
||||
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/** An empty image which can be set as src of an img element. */
|
||||
export const emptyImageBase64 =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -1,3 +1,17 @@
|
||||
import {
|
||||
mdiBattery,
|
||||
mdiBattery10,
|
||||
mdiBattery20,
|
||||
mdiBattery30,
|
||||
mdiBattery40,
|
||||
mdiBattery50,
|
||||
mdiBattery60,
|
||||
mdiBattery70,
|
||||
mdiBattery80,
|
||||
mdiBattery90,
|
||||
mdiBatteryAlertVariantOutline,
|
||||
mdiBatteryUnknown,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
|
||||
const BATTERY_ICONS = {
|
||||
@@ -12,6 +26,18 @@ const BATTERY_ICONS = {
|
||||
90: "mdi:battery-90",
|
||||
100: "mdi:battery",
|
||||
};
|
||||
const BATTERY_ICON_PATHS = {
|
||||
10: mdiBattery10,
|
||||
20: mdiBattery20,
|
||||
30: mdiBattery30,
|
||||
40: mdiBattery40,
|
||||
50: mdiBattery50,
|
||||
60: mdiBattery60,
|
||||
70: mdiBattery70,
|
||||
80: mdiBattery80,
|
||||
90: mdiBattery90,
|
||||
100: mdiBattery,
|
||||
};
|
||||
const BATTERY_CHARGING_ICONS = {
|
||||
10: "mdi:battery-charging-10",
|
||||
20: "mdi:battery-charging-20",
|
||||
@@ -57,3 +83,15 @@ export const batteryLevelIcon = (
|
||||
}
|
||||
return BATTERY_ICONS[batteryRound];
|
||||
};
|
||||
|
||||
export const batteryLevelIconPath = (batteryLevel: number | string): string => {
|
||||
const batteryValue = Number(batteryLevel);
|
||||
if (isNaN(batteryValue)) {
|
||||
return mdiBatteryUnknown;
|
||||
}
|
||||
if (batteryValue <= 5) {
|
||||
return mdiBatteryAlertVariantOutline;
|
||||
}
|
||||
const batteryRound = Math.round(batteryValue / 10) * 10;
|
||||
return BATTERY_ICON_PATHS[batteryRound];
|
||||
};
|
||||
|
||||
@@ -137,7 +137,10 @@ export const computeEntityPickerDisplay = (
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(hass);
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isUnavailableState } from "../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface EntityUnitStubConfig {
|
||||
@@ -21,32 +21,24 @@ export const computeEntityUnitDisplay = (
|
||||
stateObj: HassEntity | undefined,
|
||||
config: EntityUnitStubConfig
|
||||
): string => {
|
||||
let unit;
|
||||
if (
|
||||
stateObj &&
|
||||
!isUnavailableState(stateObj.state) &&
|
||||
(config.attribute || stateObj.attributes.device_class !== "duration")
|
||||
!stateObj ||
|
||||
stateObj.state === UNAVAILABLE ||
|
||||
stateObj.state === UNKNOWN ||
|
||||
(!config.attribute && stateObj.attributes.device_class === "duration")
|
||||
) {
|
||||
// check for an explicitly defined unit in config
|
||||
unit = config.unit;
|
||||
|
||||
if (!unit) {
|
||||
if (!config.attribute) {
|
||||
// use entity's unit_of_measurement
|
||||
const stateParts = hass.formatEntityStateToParts(stateObj);
|
||||
unit = stateParts.find((part) => part.type === "unit")?.value;
|
||||
} else {
|
||||
// use attribute's unit if available
|
||||
const attrParts = hass.formatEntityAttributeValueToParts(
|
||||
stateObj,
|
||||
config.attribute
|
||||
);
|
||||
unit = attrParts.find((part) => part.type === "unit")?.value;
|
||||
}
|
||||
}
|
||||
|
||||
return unit ?? "";
|
||||
return "";
|
||||
}
|
||||
|
||||
return "";
|
||||
// check for an explicitly defined unit in config
|
||||
if (config.unit) {
|
||||
return config.unit;
|
||||
}
|
||||
|
||||
// otherwise derive from the entity's state or attribute
|
||||
const parts = config.attribute
|
||||
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
|
||||
: hass.formatEntityStateToParts(stateObj);
|
||||
|
||||
return parts.find((part) => part.type === "unit")?.value ?? "";
|
||||
};
|
||||
|
||||
@@ -19,6 +19,40 @@ import type { LocalizeFunc } from "../translations/localize";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
|
||||
|
||||
// Domains whose state is a timezone-agnostic date and/or time string.
|
||||
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
|
||||
|
||||
// Domains whose state is a timestamp.
|
||||
const TIMESTAMP_DOMAINS = new Set([
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
]);
|
||||
|
||||
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
|
||||
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
minusSign: "value",
|
||||
plusSign: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
};
|
||||
|
||||
export const computeStateDisplay = (
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity,
|
||||
@@ -138,21 +172,10 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
}
|
||||
|
||||
if (parts.length) {
|
||||
const TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
minusSign: "value",
|
||||
plusSign: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
};
|
||||
|
||||
const valueParts: ValuePart[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const type = TYPE_MAP[part.type];
|
||||
const type = MONETARY_TYPE_MAP[part.type];
|
||||
if (!type) continue;
|
||||
const last = valueParts[valueParts.length - 1];
|
||||
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
|
||||
@@ -191,7 +214,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
return [{ type: "value", value: value }];
|
||||
}
|
||||
|
||||
if (["date", "input_datetime", "time"].includes(domain)) {
|
||||
if (DATE_TIME_DOMAINS.has(domain)) {
|
||||
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
|
||||
// Attributes aren't available, we have to use `state`.
|
||||
|
||||
@@ -250,23 +273,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
|
||||
// state is a timestamp
|
||||
if (
|
||||
[
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
].includes(domain) ||
|
||||
TIMESTAMP_DOMAINS.has(domain) ||
|
||||
(domain === "sensor" &&
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
|
||||
) {
|
||||
|
||||
@@ -117,9 +117,6 @@ export const generateEntityFilter = (
|
||||
}
|
||||
}
|
||||
if (entityCategories) {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
const category = entity?.entity_category || "none";
|
||||
if (!entityCategories.has(category)) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
|
||||
export interface EntityLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
gpsAccuracy?: number;
|
||||
}
|
||||
|
||||
const findFirstActiveZone = (
|
||||
inZones: readonly string[],
|
||||
states: HassEntities
|
||||
): HassEntity | undefined => {
|
||||
for (const zoneId of inZones) {
|
||||
const zone = states[zoneId];
|
||||
if (
|
||||
zone &&
|
||||
!zone.attributes.passive &&
|
||||
typeof zone.attributes.latitude === "number" &&
|
||||
typeof zone.attributes.longitude === "number"
|
||||
) {
|
||||
return zone;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getEntityLocation = (
|
||||
stateObj: HassEntity,
|
||||
states: HassEntities
|
||||
): EntityLocation | undefined => {
|
||||
const {
|
||||
latitude,
|
||||
longitude,
|
||||
gps_accuracy: gpsAccuracy,
|
||||
} = stateObj.attributes;
|
||||
if (typeof latitude === "number" && typeof longitude === "number") {
|
||||
return { latitude, longitude, gpsAccuracy };
|
||||
}
|
||||
|
||||
if (computeStateDomain(stateObj) !== "person") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inZones = stateObj.attributes.in_zones;
|
||||
if (!Array.isArray(inZones) || inZones.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const zone = findFirstActiveZone(inZones, states);
|
||||
if (!zone) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: zone.attributes.latitude,
|
||||
longitude: zone.attributes.longitude,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE_STATES } from "../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { stringCompare } from "../string/compare";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
@@ -253,7 +253,7 @@ export const getStatesDomain = (
|
||||
|
||||
if (!attribute) {
|
||||
// All entities can have unavailable states
|
||||
result.push(...UNAVAILABLE_STATES);
|
||||
result.push(UNAVAILABLE, UNKNOWN);
|
||||
}
|
||||
|
||||
if (!attribute && domain in FIXED_DOMAIN_STATES) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
|
||||
@@ -8,14 +8,20 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
const validState = states.some(
|
||||
(stateObj) => !isUnavailableState(stateObj.state)
|
||||
const allUnavailable = states.every(
|
||||
(stateObj) => stateObj.state === UNAVAILABLE
|
||||
);
|
||||
|
||||
if (!validState) {
|
||||
if (allUnavailable) {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
const hasValidState = states.some(
|
||||
(stateObj) => stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
|
||||
);
|
||||
if (!hasValidState) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
// Use the first state to determine the domain
|
||||
// This assumes all states in the group have the same domain
|
||||
const domain = computeStateDomain(states[0]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isUnavailableState, OFF, UNAVAILABLE } from "../../data/entity/entity";
|
||||
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
|
||||
export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
@@ -19,7 +19,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
return compareState !== UNAVAILABLE;
|
||||
}
|
||||
|
||||
if (isUnavailableState(compareState)) {
|
||||
if (compareState === UNAVAILABLE || compareState === UNKNOWN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import { updateIsInstalling } from "../../data/update";
|
||||
|
||||
export const updateIcon = (stateObj: HassEntity, state?: string) => {
|
||||
const compareState = state ?? stateObj.state;
|
||||
return compareState === "on"
|
||||
? updateIsInstalling(stateObj as UpdateEntity)
|
||||
? "mdi:package-down"
|
||||
: "mdi:package-up"
|
||||
: "mdi:package";
|
||||
// An install can be in progress even when the state is "off", e.g. when
|
||||
// downgrading firmware. Show the installing icon regardless of state.
|
||||
if (updateIsInstalling(stateObj as UpdateEntity)) {
|
||||
return "mdi:package-down";
|
||||
}
|
||||
return compareState === "on" ? "mdi:package-up" : "mdi:package";
|
||||
};
|
||||
|
||||
@@ -17,6 +17,19 @@ export interface NavigateOptions {
|
||||
// max time to wait for dialogs to close before navigating
|
||||
const DIALOG_WAIT_TIMEOUT = 500;
|
||||
|
||||
/**
|
||||
* Stash a destination URL in the current history entry's state. If the page
|
||||
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
|
||||
* on load instead of cleaning up the stale dialog state by going back.
|
||||
* The current URL is not changed.
|
||||
*/
|
||||
export const setRefreshUrl = (path: string) => {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, refreshUrl: path },
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures all dialogs are closed before navigation.
|
||||
* Returns true if navigation can proceed, false if a dialog refused to close.
|
||||
|
||||
@@ -40,6 +40,25 @@ export const numberFormatToLocale = (
|
||||
}
|
||||
};
|
||||
|
||||
// Constructing an Intl.NumberFormat is comparatively expensive, and these
|
||||
// formatters are created on every numeric state render. The number of distinct
|
||||
// (locale, options) combinations is small and bounded in practice, so cache the
|
||||
// instances instead of rebuilding them on every call.
|
||||
const numberFormatCache = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
const getNumberFormatter = (
|
||||
locale: string | string[] | undefined,
|
||||
options: Intl.NumberFormatOptions
|
||||
): Intl.NumberFormat => {
|
||||
const key = JSON.stringify([locale, options]);
|
||||
let formatter = numberFormatCache.get(key);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, options);
|
||||
numberFormatCache.set(key, formatter);
|
||||
}
|
||||
return formatter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
|
||||
*
|
||||
@@ -75,7 +94,7 @@ export const formatNumberToParts = (
|
||||
localeOptions?.number_format !== NumberFormat.none &&
|
||||
!Number.isNaN(Number(num))
|
||||
) {
|
||||
return new Intl.NumberFormat(
|
||||
return getNumberFormatter(
|
||||
locale,
|
||||
getDefaultFormatOptions(num, options)
|
||||
).formatToParts(Number(num));
|
||||
@@ -87,7 +106,7 @@ export const formatNumberToParts = (
|
||||
localeOptions?.number_format === NumberFormat.none
|
||||
) {
|
||||
// If NumberFormat is none, use en-US format without grouping.
|
||||
return new Intl.NumberFormat(
|
||||
return getNumberFormatter(
|
||||
"en-US",
|
||||
getDefaultFormatOptions(num, {
|
||||
...options,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
|
||||
import type { RelatedResult } from "../../data/search";
|
||||
|
||||
export interface RelatedIdSets {
|
||||
areas: Set<string>;
|
||||
devices: Set<string>;
|
||||
entities: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a set of related IDs for a given related result.
|
||||
* @param related - The related result to build the sets from.
|
||||
* @returns The related ID sets.
|
||||
*/
|
||||
export const buildRelatedIdSets = (related?: RelatedResult): RelatedIdSets => ({
|
||||
areas: new Set(related?.area || []),
|
||||
devices: new Set(related?.device || []),
|
||||
entities: new Set(related?.entity || []),
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable partition sort: related items float to the top,
|
||||
* preserving relative order (e.g. Fuse score) within each group.
|
||||
* @param items - The items to sort.
|
||||
* @returns The sorted items.
|
||||
*/
|
||||
export const sortRelatedFirst = (
|
||||
items: PickerComboBoxItem[]
|
||||
): PickerComboBoxItem[] =>
|
||||
[...items].sort((a, b) => {
|
||||
const aRelated = Boolean(a.isRelated);
|
||||
const bRelated = Boolean(b.isRelated);
|
||||
if (aRelated === bRelated) {
|
||||
return 0;
|
||||
}
|
||||
return aRelated ? -1 : 1;
|
||||
});
|
||||