Compare commits

..

5 Commits

Author SHA1 Message Date
Bram Kragten 3abd355004 add floor_id and label_id to YAML hover tooltip selector type mapping 2026-05-13 20:24:12 +02:00
Bram Kragten 7b2a90b967 Add to developer tools action 2026-05-13 20:19:39 +02:00
Bram Kragten 1d633601f0 add built-in triggers and conditions, add floor and label 2026-05-13 20:15:54 +02:00
Bram Kragten 94148702e8 Add support for numeric threshold and behavior selectors 2026-05-13 18:16:40 +02:00
Bram Kragten e5548065ba Add field-aware YAML completions, hover tooltips, and linting to automation editor
- New files: yaml_field_schema.ts (types), yaml_ha_completions.ts (completion/hover/lint
  sources), ha_completion_items.ts (entity/device/area completion builders),
  ha-code-editor-yaml-hover.ts (key hover tooltip element),
  yaml_schema_helpers.ts (action/trigger/condition schema converters)
- ha-code-editor: wire schema completions, hover tooltips, and schema linter;
  replace setDiagnostics-based syntax error reporting with a linter() so syntax
  and schema diagnostics no longer overwrite each other; add forceLinting export
- ha-yaml-editor: suppress generic entity autocomplete when schema is present
- Action/trigger/condition editors: pass yamlFieldSchema to ha-yaml-editor;
  memoize on (services, localize) not full hass to avoid constant re-renders
- codemirror.ts: export forceLinting; mount-aware BlockMapping traversal in lint
  source to handle jinja({ base: yaml() }) tree structure (Template → Text with
  NodeProp.mounted subtree)
2026-05-13 17:44:20 +02:00
849 changed files with 16141 additions and 29702 deletions
+64 -84
View File
@@ -8,7 +8,6 @@ You are an assistant helping with development of the Home Assistant frontend. Th
- [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)
@@ -41,7 +40,7 @@ script/develop # Development server
```typescript
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
```
## Core Architecture
@@ -53,64 +52,13 @@ 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 (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility
- Prettier with ES5 trailing commas enforced
- No console statements (`no-console: "error"`) - use proper logging
- Import organization: No unused imports, consistent type imports
@@ -188,7 +136,6 @@ 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
@@ -213,7 +160,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
- **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
- **Follow Material Design**: Use Material Web Components where appropriate
- **Support RTL**: Ensure all layouts work in RTL languages
```typescript
@@ -320,22 +267,15 @@ fireEvent(this, "show-dialog", {
**Dialog Sizing:**
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
- Custom sizing is NOT recommended - use the standard width presets
**Button Appearance Guidelines:**
`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`)
- **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)
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
**Gallery Documentation:**
@@ -368,8 +308,7 @@ Common patterns:
### Alert Component (ha-alert)
- Types: `error`, `warning`, `info`, `success`
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
- Slots: `icon` (override the leading icon), `action` (custom action content)
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
- Content announced by screen readers when dynamically displayed
```html
@@ -510,7 +449,7 @@ this.hass.localize("ui.panel.config.updates.update_available", {
### Common Pitfalls to Avoid
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
- Don't use `querySelector` - Use refs 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
@@ -601,24 +540,35 @@ When creating a pull request, you **must** use the PR template located at `.gith
#### Translation Considerations
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:
- **Add translation keys**: All user-facing text must be translatable
- **Use placeholders**: Support dynamic content in translations
- **Keep context**: Provide enough context for translators
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
```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?");
```
### 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 (hover, active, focus)
- **Visual feedback**: Provide clear indication of interactive states
#### 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
@@ -630,12 +580,15 @@ Recurring, easy-to-miss problems surfaced in real PR reviews. These complement t
- **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
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
- **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
#### Configuration and Props
@@ -646,12 +599,39 @@ Recurring, easy-to-miss problems surfaced in real PR reviews. These complement t
## Review Guidelines
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.
### Core Requirements Checklist
- [ ] `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)
- [ ] 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
- [ ] Components handle all states (loading, error, unavailable)
- [ ] Entity existence checked before property access
- [ ] Event/subscription listeners cleaned up (no memory leaks)
- [ ] Accessible to screen readers and keyboard
- [ ] Event subscriptions properly cleaned up
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=cast/dist --alias dev
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --alias dev
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
@@ -82,7 +82,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=cast/dist --prod
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
# ️ 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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+2 -2
View File
@@ -47,7 +47,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=demo/dist --prod
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
@@ -83,7 +83,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=demo/dist --prod
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=gallery/dist --prod
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Deploy preview to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
--json > deploy_output.json
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with:
sync-labels: true
+5 -3
View File
@@ -13,11 +13,13 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
github-token: ${{ github.token }}
process-only: "issues, prs"
issue-inactive-days: "30"
issue-lock-inactive-days: "30"
issue-exclude-created-before: "2020-10-01T00:00:00Z"
issue-lock-reason: ""
pr-inactive-days: "1"
pr-lock-inactive-days: "1"
pr-exclude-created-before: "2020-11-01T00:00:00Z"
pr-lock-reason: ""
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
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@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -16
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -77,22 +77,9 @@ 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
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90
+1 -1
View File
@@ -1 +1 @@
24.16.0
24.15.0
@@ -1,86 +0,0 @@
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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.15.0.cjs
yarnPath: .yarn/releases/yarn-4.14.1.cjs
+1 -3
View File
@@ -57,9 +57,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent === "") {
hasDescription = false;
} else {
descriptionContent = marked(descriptionContent)
.replace(/\\/g, "\\\\")
.replace(/`/g, "\\`");
descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
fs.writeFileSync(
path.resolve(galleryBuild, `${pageId}-description.ts`),
+1 -1
View File
@@ -29,7 +29,7 @@ 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",
version: "5.6.0",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
+2
View File
@@ -1,3 +1,5 @@
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";
+13 -33
View File
@@ -1,3 +1,5 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
@@ -5,12 +7,9 @@ import { customElement, query, state } from "lit/decorators";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
import type { HaDrawer } from "../../src/components/ha-drawer";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import { PAGES, SIDEBAR } from "../build/import-pages";
@@ -40,8 +39,8 @@ class HaGallery extends LitElement {
@query("notification-manager")
private _notifications!: HTMLElementTagNameMap["notification-manager"];
@query("ha-drawer")
private _drawer!: HaDrawer;
@query("mwc-drawer")
private _drawer!: HTMLElementTagNameMap["mwc-drawer"];
private _narrow = window.matchMedia("(max-width: 600px)").matches;
@@ -76,15 +75,16 @@ class HaGallery extends LitElement {
}
return html`
<ha-drawer
.direction=${this._rtl ? "rtl" : "ltr"}
<mwc-drawer
hasHeader
.open=${!this._narrow}
.type=${this._narrow ? "modal" : "dismissible"}
>
<div class="drawer-title">Home Assistant Design</div>
<span slot="title">Home Assistant Design</span>
<!-- <span slot="subtitle">subtitle</span> -->
<div class="sidebar">${sidebar}</div>
<div slot="appContent" class="app-content">
<ha-top-app-bar-fixed>
<div slot="appContent">
<mwc-top-app-bar-fixed>
<ha-icon-button
slot="navigationIcon"
@click=${this._menuTapped}
@@ -94,7 +94,7 @@ class HaGallery extends LitElement {
<div slot="title">
${PAGES[this._page].metadata.title || this._page.split("/")[1]}
</div>
</ha-top-app-bar-fixed>
</mwc-top-app-bar-fixed>
<div class="content">
${PAGES[this._page].description
? html`
@@ -144,7 +144,7 @@ class HaGallery extends LitElement {
</div>
</div>
</div>
</ha-drawer>
</mwc-drawer>
<notification-manager
.hass=${FAKE_HASS}
id="notifications"
@@ -226,28 +226,12 @@ class HaGallery extends LitElement {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 256px;
--header-height: 64px;
}
.sidebar {
box-sizing: border-box;
max-height: calc(100vh - var(--header-height));
overflow-y: auto;
padding: 4px;
}
.drawer-title {
align-items: center;
box-sizing: border-box;
color: var(--primary-text-color);
display: flex;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
min-height: var(--header-height);
padding: 0 16px;
}
.sidebar a {
color: var(--primary-text-color);
display: block;
@@ -271,17 +255,13 @@ class HaGallery extends LitElement {
opacity: 0.12;
}
.app-content {
div[slot="appContent"] {
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 {
flex: 1;
}
+2 -2
View File
@@ -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="s" slot="action">restart</ha-button>`,
actionSlot: html`<ha-button size="small" slot="action">restart</ha-button>`,
},
{
title: "Unsaved data",
description: "You have unsaved data",
type: "warning",
actionSlot: html`<ha-button size="s" slot="action">save</ha-button>`,
actionSlot: html`<ha-button size="small" slot="action">save</ha-button>`,
},
{
title: "Slotted icon",
@@ -26,7 +26,7 @@ title: Button
filled button
</ha-button>
<ha-button size="s">
<ha-button size="small">
small
</ha-button>
</div>
@@ -34,7 +34,7 @@ title: Button
```html
<ha-button> simple button </ha-button>
<ha-button size="s"> small </ha-button>
<ha-button size="small"> 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 | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
| size | "small"/"medium"/"large" | "medium" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
| disabled | Boolean | false | Disables the button and prevents user interaction. |
+2 -2
View File
@@ -49,7 +49,7 @@ export class DemoHaButton extends LitElement {
<ha-button
.appearance=${appearance}
.variant=${variant}
size="s"
size="small"
>
${titleCase(`${variant} ${appearance}`)}
</ha-button>
@@ -100,7 +100,7 @@ export class DemoHaButton extends LitElement {
<ha-button
.variant=${variant}
.appearance=${appearance}
size="s"
size="small"
disabled
>
${titleCase(`${appearance}`)}
@@ -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 **has made changes to**. 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 needs to fill out. 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`.
@@ -62,11 +62,10 @@ host reflects `aria-multiselectable`.
**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`).
- `ha-list-selected`selection changed. Detail
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
`index` is a `number` in single mode (`-1` when nothing selected) and a
`Set<number>` in multi mode.
**Methods / getters**
+13 -53
View File
@@ -20,6 +20,7 @@ import "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-base";
import "../../../../src/components/list/ha-list-nav";
import "../../../../src/components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
type Appearance = "line" | "checkbox";
type Position = "start" | "end";
@@ -184,7 +185,7 @@ export class DemoHaList extends LitElement {
<ha-card header="Single select, appearance=line">
<ha-list-selectable
aria-label="Single select"
@ha-list-item-selected=${this._onSingle}
@ha-list-selected=${this._onSingle}
>
${this._options.map(
(o, i) => html`
@@ -204,8 +205,7 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi select line"
@ha-list-item-selected=${this._onMultiLineSelected}
@ha-list-item-deselected=${this._onMultiLineDeselected}
@ha-list-selected=${this._onMultiLine}
>
${this._options.map(
(o, i) => html`
@@ -227,8 +227,7 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi checkbox start"
@ha-list-item-selected=${this._onMultiCheckStartSelected}
@ha-list-item-deselected=${this._onMultiCheckStartDeselected}
@ha-list-selected=${this._onMultiCheckStart}
>
${this._options.map(
(o, i) => html`
@@ -254,8 +253,7 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
<ha-list-selectable
multi
aria-label="Multi checkbox end"
@ha-list-item-selected=${this._onMultiCheckEndSelected}
@ha-list-item-deselected=${this._onMultiCheckEndDeselected}
@ha-list-selected=${this._onMultiCheckEnd}
>
${this._options.map(
(o, i) => html`
@@ -349,58 +347,20 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
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 _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
this._single = ev.detail.index;
};
private _onMultiLineSelected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, true);
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiLine = ev.detail.index;
};
private _onMultiLineDeselected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, false);
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckStart = ev.detail.index;
};
private _onMultiCheckStartSelected = (ev: CustomEvent<number>) => {
this._multiCheckStart = this._withIndex(
this._multiCheckStart,
ev.detail,
true
);
};
private _onMultiCheckStartDeselected = (ev: CustomEvent<number>) => {
this._multiCheckStart = this._withIndex(
this._multiCheckStart,
ev.detail,
false
);
};
private _onMultiCheckEndSelected = (ev: CustomEvent<number>) => {
this._multiCheckEnd = this._withIndex(this._multiCheckEnd, ev.detail, true);
};
private _onMultiCheckEndDeselected = (ev: CustomEvent<number>) => {
this._multiCheckEnd = this._withIndex(
this._multiCheckEnd,
ev.detail,
false
);
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckEnd = ev.detail.index;
};
static styles = css`
@@ -17,13 +17,13 @@ subtitle: A slider component for selecting a value from a range.
### Example Usage
<div class="wrapper">
<ha-slider size="s" with-markers min="0" max="8" value="4"></ha-slider>
<ha-slider size="m"></ha-slider>
<ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider>
<ha-slider size="medium"></ha-slider>
</div>
```html
<ha-slider size="s" with-markers min="0" max="8" value="4"></ha-slider>
<ha-slider size="m"></ha-slider>
<ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider>
<ha-slider size="medium"></ha-slider>
```
### API
+2 -2
View File
@@ -29,7 +29,7 @@ export class DemoHaSlider extends LitElement {
></ha-slider>
<span>Small</span>
<ha-slider
size="s"
size="small"
min="0"
max="8"
value="4"
@@ -37,7 +37,7 @@ export class DemoHaSlider extends LitElement {
></ha-slider>
<span>Medium</span>
<ha-slider
size="m"
size="medium"
min="0"
max="8"
value="4"
+10 -21
View File
@@ -1,12 +1,10 @@
import { provide } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import type { TemplateResult, PropertyValues } from "lit";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../../../src/components/ha-tip";
import { internationalizationContext } from "../../../../src/data/context";
import type { HomeAssistantInternationalization } from "../../../../src/types";
import "../../../../src/components/ha-card";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import { provideHass } from "../../../../src/fake_data/provide_hass";
const tips: (string | TemplateResult)[] = [
"Test tip",
@@ -16,25 +14,16 @@ const tips: (string | TemplateResult)[] = [
@customElement("demo-components-ha-tip")
export class DemoHaTip extends LitElement {
@provide({ context: internationalizationContext })
@state()
protected _i18n: HomeAssistantInternationalization = {
localize: ((key: string) => key) as any,
language: "en",
selectedLanguage: null,
locale: {} as any,
translationMetadata: {} as any,
loadBackendTranslation: (async () => (key: string) => key) as any,
loadFragmentTranslation: (async () => (key: string) => key) as any,
};
protected render(): TemplateResult {
return html` ${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-tip ${mode} demo">
<div class="card-content">
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
${tips.map(
(tip) =>
html`<ha-tip .hass=${provideHass(this)}>${tip}</ha-tip>`
)}
</div>
</ha-card>
</div>
-31
View File
@@ -13,28 +13,6 @@ export interface NetworkInfo {
supervisor_internet: boolean;
}
interface SupervisorJob {
name: string;
reference: string | null;
uuid: string;
progress: number; // float, 0100
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[];
@@ -79,15 +57,6 @@ 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
+6 -58
View File
@@ -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,7 +15,6 @@ import { haStyle } from "../../src/resources/styles";
import "./components/landing-page-logs";
import "./components/landing-page-network";
import {
getSupervisorJobsInfo,
getSupervisorNetworkInfo,
pingSupervisor,
type NetworkInfo,
@@ -25,7 +24,6 @@ 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 {
@@ -41,8 +39,6 @@ class HaLandingPage extends LandingPageBaseElement {
@state() private _coreCheckActive = false;
@state() private _progress = -1;
private _mobileApp =
extractSearchParam("redirect_uri") === "homeassistant://auth-callback";
@@ -64,14 +60,7 @@ class HaLandingPage extends LandingPageBaseElement {
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<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
>
<ha-progress-bar indeterminate></ha-progress-bar>
`
: nothing}
${networkIssue || this._networkInfoError
@@ -137,7 +126,6 @@ class HaLandingPage extends LandingPageBaseElement {
import("../../src/components/ha-language-picker");
this._fetchSupervisorInfo(true);
this._fetchSupervisorJobsInfo();
}
private _scheduleFetchSupervisorInfo() {
@@ -150,13 +138,6 @@ class HaLandingPage extends LandingPageBaseElement {
);
}
private _scheduleFetchSupervisorJobsInfo() {
setTimeout(
() => this._fetchSupervisorJobsInfo(),
SCHEDULE_FETCH_JOBS_INFO_SECONDS * 1000
);
}
private _scheduleTurnOffCoreCheck() {
setTimeout(() => {
this._coreCheckActive = false;
@@ -184,7 +165,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("Failed to fetch supervisor info", err);
console.error(err);
this._networkInfoError = true;
}
}
@@ -194,33 +175,6 @@ 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");
@@ -268,27 +222,21 @@ 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
View File
@@ -1,2 +1,3 @@
[build.environment]
YARN_VERSION = "1.22.11"
NODE_OPTIONS = "--max_old_space_size=6144"
+58 -49
View File
@@ -27,7 +27,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.29.7",
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/commands": "6.10.3",
@@ -37,72 +37,78 @@
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@codemirror/view": "6.42.1",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.7",
"@formatjs/intl-displaynames": "7.3.9",
"@formatjs/intl-durationformat": "0.10.13",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.9",
"@formatjs/intl-locale": "5.3.8",
"@formatjs/intl-numberformat": "9.3.10",
"@formatjs/intl-pluralrules": "6.3.9",
"@formatjs/intl-relativetimeformat": "12.3.9",
"@formatjs/intl-datetimeformat": "7.4.2",
"@formatjs/intl-displaynames": "7.3.5",
"@formatjs/intl-durationformat": "0.10.8",
"@formatjs/intl-getcanonicallocales": "3.2.6",
"@formatjs/intl-listformat": "8.3.5",
"@formatjs/intl-locale": "5.3.5",
"@formatjs/intl-numberformat": "9.3.5",
"@formatjs/intl-pluralrules": "6.3.5",
"@formatjs/intl-relativetimeformat": "12.3.5",
"@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.7.0-ha.0",
"@home-assistant/webawesome": "3.3.1-ha.3",
"@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-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.23",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.1.2",
"@tsparticles/preset-links": "4.1.2",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.0",
"barcode-detector": "3.1.3",
"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.4.0",
"date-fns": "4.1.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts": "6.0.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.1",
"fuse.js": "7.3.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.4",
"intl-messageformat": "11.2.7",
"js-yaml": "4.2.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.4",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "18.0.4",
"marked": "18.0.3",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -114,7 +120,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch",
"tinykeys": "3.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -125,20 +131,21 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.7",
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.5",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.61.0",
"@html-eslint/eslint-plugin": "0.60.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.12",
"@rspack/core": "2.0.5",
"@rspack/dev-server": "2.0.3",
"@rsdoctor/rspack-plugin": "1.5.10",
"@rspack/core": "2.0.2",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -150,25 +157,27 @@
"@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",
"@vitest/coverage-v8": "4.1.8",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.5",
"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.4.1",
"eslint": "10.3.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.3.1",
"eslint-plugin-lit": "2.2.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.5",
"generate-license-file": "4.2.1",
"generate-license-file": "4.1.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -179,8 +188,8 @@
"husky": "9.1.7",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.7",
"license-checker-rseidelsohn": "4.4.2",
"lint-staged": "17.0.4",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -190,20 +199,20 @@
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.16",
"terser-webpack-plugin": "5.6.1",
"tar": "7.5.15",
"terser-webpack-plugin": "5.6.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.60.1",
"typescript-eslint": "8.59.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
},
"resolutions": {
"lit": "3.3.3",
"lit-html": "3.3.3",
"lit": "3.3.2",
"lit-html": "3.3.2",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
@@ -212,8 +221,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.15.0",
"packageManager": "yarn@4.14.1",
"volta": {
"node": "24.16.0"
"node": "24.15.0"
}
}

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

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

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.0"
version = "20260429.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
-39
View File
@@ -18,46 +18,7 @@
"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"],
+3 -4
View File
@@ -54,8 +54,6 @@ export class HaAuthFlow extends LitElement {
@query("ha-auth-form") private _form?: HaAuthForm;
@query("ha-form") private _haForm?: HTMLElement;
createRenderRoot() {
return this;
}
@@ -162,8 +160,9 @@ export class HaAuthFlow extends LitElement {
// 100ms to give all the form elements time to initialize.
setTimeout(() => {
if (this._haForm) {
(this._haForm as any).focus();
const form = this.renderRoot.querySelector("ha-form");
if (form) {
(form as any).focus();
}
}, 100);
}
+1 -1
View File
@@ -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: readonly T[]): T[][] {
export function getAllCombinations<T>(arr: T[]) {
return arr.reduce<T[][]>(
(combinations, element) =>
combinations.concat(
+6
View File
@@ -5,6 +5,7 @@ import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
(isCore(page) || isLoadedIntegration(hass, page)) &&
!hideAdvancedPage(hass, page) &&
isNotLoadedIntegration(hass, page);
export const isLoadedIntegration = (
@@ -26,3 +27,8 @@ export const isNotLoadedIntegration = (
);
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);
-9
View File
@@ -114,15 +114,6 @@ 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";
@@ -1,59 +0,0 @@
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 -15
View File
@@ -1,20 +1,6 @@
import timezones from "google-timezones-json";
import { TimeZone } from "../../data/translation";
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;
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// 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";
+21 -89
View File
@@ -1,17 +1,8 @@
import { consume } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../../types";
import {
entitiesContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { HomeAssistant } from "../../types";
import { entitiesContext, 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 {
@@ -27,28 +18,6 @@ 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,
@@ -86,52 +55,27 @@ export const consumeEntityState = (config: ConsumeEntryConfig) =>
);
/**
* 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.
* 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).
*/
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;
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;
}
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
@@ -147,15 +91,3 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
return typeof id === "string" ? entities?.[id] : undefined;
}
);
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed — the decorated property updates
* whenever the i18n context changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
internationalizationContext,
undefined,
({ localize }) => localize
);
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { isUnavailableState } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
interface EntityUnitStubConfig {
@@ -21,24 +21,32 @@ export const computeEntityUnitDisplay = (
stateObj: HassEntity | undefined,
config: EntityUnitStubConfig
): string => {
let unit;
if (
!stateObj ||
stateObj.state === UNAVAILABLE ||
stateObj.state === UNKNOWN ||
(!config.attribute && stateObj.attributes.device_class === "duration")
stateObj &&
!isUnavailableState(stateObj.state) &&
(config.attribute || stateObj.attributes.device_class !== "duration")
) {
return "";
// 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 ?? "";
}
// 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 ?? "";
return "";
};
-59
View File
@@ -1,59 +0,0 @@
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,
};
};
+2 -2
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { UNAVAILABLE_STATES } 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, UNKNOWN);
result.push(...UNAVAILABLE_STATES);
}
if (!attribute && domain in FIXED_DOMAIN_STATES) {
+5 -11
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain";
@@ -8,18 +8,12 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
return UNAVAILABLE;
}
const allUnavailable = states.every(
(stateObj) => stateObj.state === UNAVAILABLE
const validState = states.some(
(stateObj) => !isUnavailableState(stateObj.state)
);
if (allUnavailable) {
return UNAVAILABLE;
}
const hasValidState = states.some(
(stateObj) => stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
if (!hasValidState) {
return UNKNOWN;
if (!validState) {
return UNAVAILABLE;
}
// Use the first state to determine the domain
+2 -2
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { isUnavailableState, OFF, UNAVAILABLE } 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 (compareState === UNAVAILABLE || compareState === UNKNOWN) {
if (isUnavailableState(compareState)) {
return false;
}
-13
View File
@@ -17,19 +17,6 @@ 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.
-37
View File
@@ -1,37 +0,0 @@
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;
});
@@ -1,17 +0,0 @@
/**
* @summary Truncates a string to `maxLength`, appending `ellipsis` only when it actually shortens the result.
* @param text The input string.
* @param maxLength Maximum length of the prefix kept before the ellipsis.
* @param ellipsis Suffix appended when truncation occurs.
* @returns `text` unchanged when its length is `<= maxLength + ellipsis.length`, otherwise `text.substring(0, maxLength) + ellipsis`.
*/
export const truncateWithEllipsis = (
text: string,
maxLength: number,
ellipsis = "..."
): string => {
if (text.length <= maxLength + ellipsis.length) {
return text;
}
return `${text.substring(0, maxLength)}${ellipsis}`;
};
@@ -1,73 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import {
createQueryString,
decodeQueryParams,
queryParamsFromServiceTarget,
serviceTargetFromQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export type HistoryLogbookTargetParamKey =
| "entity_id"
| "label_id"
| "floor_id"
| "area_id"
| "device_id";
export type HistoryLogbookDateParamKey = "start_date" | "end_date";
export type HistoryLogbookBooleanParamKey = "back";
export type HistoryLogbookQueryParams = QueryParamValues<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const historyLogbookTargetParamKeys: HistoryLogbookTargetParamKey[] = [
"entity_id",
"label_id",
"floor_id",
"area_id",
"device_id",
];
export const historyLogbookQueryParamConfig = {
list: historyLogbookTargetParamKeys,
date: ["start_date", "end_date"],
boolean: [{ key: "back", trueValue: "1" }],
} satisfies QueryParamConfig<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const decodeHistoryLogbookQueryParams = (
searchParams: SearchParamsSource
): HistoryLogbookQueryParams =>
decodeQueryParams(searchParams, historyLogbookQueryParamConfig);
export const historyLogbookTargetFromQueryParams = (
params: HistoryLogbookQueryParams
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
startDate: Date,
endDate: Date
): string => {
const queryString = createQueryString(
{
...queryParamsFromServiceTarget(target, historyLogbookTargetParamKeys),
start_date: startDate,
end_date: endDate,
},
historyLogbookQueryParamConfig
);
return queryString ? `${path}?${queryString}` : path;
};
-140
View File
@@ -1,140 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../array/ensure-array";
export type SearchParamsSource =
| URLSearchParams
| Record<string, string>
| string;
export interface QueryParamConfig<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> {
list?: readonly ListKey[];
date?: readonly DateKey[];
boolean?: readonly {
key: BooleanKey;
trueValue: string;
}[];
}
export type QueryParamValues<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> = Partial<
Record<ListKey, string[]> &
Record<DateKey, Date> &
Record<BooleanKey, boolean>
>;
export type ServiceTargetQueryParams<
Key extends keyof HassServiceTarget & string,
> = Partial<Record<Key, string[]>>;
const getSearchParam = (
searchParams: SearchParamsSource,
key: string
): string | null => {
if (typeof searchParams === "string") {
return new URLSearchParams(searchParams).get(key);
}
if (searchParams instanceof URLSearchParams) {
return searchParams.get(key);
}
return searchParams[key] ?? null;
};
export const decodeQueryParams = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
searchParams: SearchParamsSource,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): QueryParamValues<ListKey, DateKey, BooleanKey> => {
const params: QueryParamValues<ListKey, DateKey, BooleanKey> = {};
for (const key of config.list ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value.split(",") as (typeof params)[typeof key];
}
}
for (const key of config.date ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = new Date(value) as (typeof params)[typeof key];
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (getSearchParam(searchParams, key) === trueValue) {
params[key] = true as (typeof params)[typeof key];
}
}
return params;
};
export const createQueryString = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
values: QueryParamValues<ListKey, DateKey, BooleanKey>,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): string => {
const searchParams = new URLSearchParams();
for (const key of config.list ?? []) {
const value = values[key] as string[] | undefined;
if (value?.length) {
searchParams.append(key, value.join(","));
}
}
for (const key of config.date ?? []) {
const value = values[key] as Date | undefined;
if (value) {
searchParams.append(key, value.toISOString());
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (values[key]) {
searchParams.append(key, trueValue);
}
}
return searchParams.toString();
};
export const serviceTargetFromQueryParams = <
Key extends keyof HassServiceTarget & string,
>(
params: ServiceTargetQueryParams<Key>,
keys: readonly Key[]
): HassServiceTarget | undefined => {
if (!keys.some((key) => params[key])) {
return undefined;
}
const target: HassServiceTarget = {};
for (const key of keys) {
const value = params[key];
if (value) {
target[key] = value;
}
}
return target;
};
export const queryParamsFromServiceTarget = <
Key extends keyof HassServiceTarget & string,
>(
target: HassServiceTarget,
keys: readonly Key[]
): ServiceTargetQueryParams<Key> => {
const params: ServiceTargetQueryParams<Key> = {};
for (const key of keys) {
const value = target[key];
if (value) {
params[key] = ensureArray(value);
}
}
return params;
};
@@ -1,70 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
/**
* @element ha-automation-row-live-test
*
* @summary
* Small status indicator dot used in automation/condition rows to surface the
* live evaluation result.
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@property({ reflect: true }) public state: LiveTestState = "unknown";
@property() public label = "";
protected render() {
return html`
<div
id="indicator"
role="status"
tabindex="0"
aria-label=${this.label}
></div>
`;
}
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 10px;
height: 10px;
border-radius: var(--ha-border-radius-circle);
border: var(--ha-border-width-md) solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-orange-60);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-red-60);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-neutral-60);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-live-test": HaAutomationRowLiveTest;
}
}
@@ -128,9 +128,7 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
padding: 0 0 0 var(--ha-space-3);
min-height: 48px;
align-items: flex-start;
cursor: pointer;
@@ -146,8 +144,6 @@ export class HaAutomationRow extends LitElement {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: calc(var(--ha-space-2) * -1);
margin-inline-start: calc(var(--ha-space-2) * -1);
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
@@ -165,8 +161,7 @@ export class HaAutomationRow extends LitElement {
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
:host([building-block]) ::slotted(#condition-icon) {
:host([building-block]) ::slotted([slot="leading-icon"]) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
@@ -192,6 +187,7 @@ export class HaAutomationRow extends LitElement {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
margin: 0 var(--ha-space-3);
}
::slotted([slot="header"]) {
overflow-wrap: anywhere;
+1 -1
View File
@@ -116,7 +116,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
:host([appearance="brand"]) ha-svg-icon {
ha-svg-icon {
color: var(--white-color);
}
`;
@@ -1,40 +0,0 @@
import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
* For axis-trigger time-series tooltips where the cursor's Y is uncorrelated
* with the displayed content.
*/
export const sideTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};
+50 -86
View File
@@ -14,13 +14,12 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
@@ -30,59 +29,22 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type {
ECOption,
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
} from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
const toEChartsFormatter = (
fn: ReturnType<typeof wrapLitTooltipFormatter>
): NonNullable<TooltipOption["formatter"]> =>
fn as NonNullable<TooltipOption["formatter"]>;
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
const { formatter, ...rest } = tooltip;
const next: TooltipOption = { ...rest };
if (typeof formatter === "function") {
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
} else if (formatter !== undefined) {
next.formatter = formatter;
}
return next;
};
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
if (s.tooltip && typeof s.tooltip.formatter === "function") {
return {
...s,
tooltip: convertHaTooltipFormatter(s.tooltip),
} as RawSeriesOption;
}
return s as RawSeriesOption;
};
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
@@ -104,9 +66,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: HaECSeries = [];
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public options?: HaECOption;
@property({ attribute: false }) public options?: ECOption;
@property({ type: String }) public height?: string;
@@ -140,8 +102,6 @@ export class HaChartBase extends LitElement {
@state() private _hiddenDatasets = new Set<string>();
@query(".chart") private _chartContainer?: HTMLDivElement;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -509,6 +469,7 @@ export class HaChartBase extends LitElement {
private async _setupChart() {
if (this._loading) return;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
this._loading = true;
try {
if (this.chart) {
@@ -523,7 +484,7 @@ export class HaChartBase extends LitElement {
const style = getComputedStyle(this);
echarts.registerTheme("custom", this._createTheme(style));
this.chart = echarts.init(this._chartContainer!, "custom");
this.chart = echarts.init(container, "custom");
this.chart.on("datazoom", (e: any) => {
this._handleDataZoomEvent(e);
});
@@ -652,7 +613,7 @@ export class HaChartBase extends LitElement {
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: HaECOption | undefined,
options: ECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
@@ -672,7 +633,7 @@ export class HaChartBase extends LitElement {
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
@@ -795,34 +756,22 @@ export class HaChartBase extends LitElement {
xAxis,
};
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
// pollute that cache across chart instances).
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
next.triggerOn = "click";
}
return next;
};
const haTooltip = options.tooltip;
const processedTooltip = Array.isArray(haTooltip)
? haTooltip.map(processTooltip)
: processTooltip(haTooltip);
return {
...options,
tooltip: processedTooltip,
} as ECOption;
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
}
return options as ECOption;
return options;
}
private _createTheme(style: CSSStyleDeclaration) {
@@ -1006,16 +955,30 @@ export class HaChartBase extends LitElement {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
| undefined;
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
const series = ensureArray(this.data).map((s) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
let result = {
...s,
data,
} as HaECSeriesItem;
if (data && s.type === "line") {
if ((s as LineSeriesOption).sampling === "minmax") {
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return {
...s,
data: (data as LineSeriesOption["data"])!.map((v) =>
Array.isArray(v)
? [
v[0],
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
...v.slice(2),
]
: v
),
};
}
if (s.sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
? xAxis.min.getTime()
@@ -1030,8 +993,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
result = {
...result,
return {
...s,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -1039,10 +1002,11 @@ export class HaChartBase extends LitElement {
minX,
maxX
),
} as HaECSeriesItem;
};
}
}
return processSeriesTooltipFormatter(result);
const name = filterXSS(String(s.name ?? s.id ?? ""));
return { ...s, name, data };
});
return series as ECOption["series"];
}
@@ -1379,8 +1343,8 @@ export class HaChartBase extends LitElement {
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
@@ -1,41 +0,0 @@
import type { PropertyValues } from "lit";
import { css, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-chart-tooltip-marker")
class HaChartTooltipMarker extends LitElement {
@property() public color = "";
@property({ type: Boolean, reflect: true }) public rtl = false;
protected willUpdate(changed: PropertyValues) {
if (changed.has("color")) {
this.style.backgroundColor = this.color;
}
}
protected render() {
return nothing;
}
static styles = css`
:host {
display: inline-block;
margin-inline-end: 4px;
margin-inline-start: initial;
border-radius: 10px;
width: 10px;
height: 10px;
vertical-align: middle;
}
:host([rtl]) {
direction: rtl;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-tooltip-marker": HaChartTooltipMarker;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues, TemplateResult } from "lit";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -11,7 +11,7 @@ import type {
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => TemplateResult | typeof nothing | null;
) => string;
/**
* Optional callback that returns additional searchable strings for a node.
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): HaECOption => ({
(categories?: NetworkData["categories"]): ECOption => ({
tooltip: {
trigger: "item",
confine: true,
+6 -14
View File
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
});
render() {
const options: HaECOption = {
const options = {
grid: {
top: 0,
bottom: 0,
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
formatter: this._renderTooltip,
appendTo: document.body,
},
};
} as ECOption;
return html`<ha-chart-base
.hass=${this.hass}
@@ -101,22 +101,14 @@ export class HaSankeyChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
// Keep numbers and units left-to-right, even in RTL locales.
const formattedValue = html`<div style="direction:ltr; display: inline;">
${value}
</div>`;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${formattedValue}`;
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${formattedValue}`;
return `${filterXSS(source?.label ?? data.source)} ${filterXSS(target?.label ?? data.target)}<br>${value}`;
}
return null;
};
+8 -8
View File
@@ -5,9 +5,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import type { HaECOption } from "../../resources/echarts/echarts";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
@@ -24,6 +25,8 @@ export interface SunburstNode {
@customElement("ha-sunburst-chart")
export class HaSunburstChart extends LitElement {
public hass!: HomeAssistant;
@property({ attribute: false }) public data?: SunburstNode;
@property({ attribute: false }) public valueFormatter?: (
@@ -47,13 +50,13 @@ export class HaSunburstChart extends LitElement {
return nothing;
}
const options: HaECOption = {
const options = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
};
} as ECOption;
return html`<ha-chart-base
.data=${this._createData(this.data)}
@@ -68,10 +71,7 @@ export class HaSunburstChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${data.name}<br />${value}`;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
};
private _createData = memoizeOne(
@@ -1,41 +0,0 @@
import { nothing, render } from "lit";
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
type WrappedTooltipFormatter = (
params: unknown,
ticket?: string
) => HTMLElement | null;
export type { WrappedTooltipFormatter };
const litTooltipFormatterCache = new WeakMap<
LitTooltipFormatter | WrappedTooltipFormatter,
WrappedTooltipFormatter
>();
export const wrapLitTooltipFormatter = (
fn: LitTooltipFormatter | WrappedTooltipFormatter
): WrappedTooltipFormatter => {
const cached = litTooltipFormatterCache.get(fn);
if (cached) return cached;
const container = document.createElement("div");
// display:contents keeps the wrapper layout-invisible so its children act as
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
container.style.display = "contents";
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
const result = (fn as LitTooltipFormatter)(params, ticket);
// `nothing` and null/undefined must all suppress the tooltip. Returning
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
// comment marker behind so echarts would show an empty box; convert it to
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
if (result === null || result === undefined || result === nothing) {
return null;
}
render(result, container);
return container;
};
litTooltipFormatterCache.set(fn, wrapped);
// Idempotent re-wrap: looking up the wrapped fn returns itself.
litTooltipFormatterCache.set(wrapped, wrapped);
return wrapped;
};
@@ -1,5 +1,5 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
@@ -11,10 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
@@ -25,6 +22,7 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
@@ -108,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@@ -118,7 +116,9 @@ export class StateHistoryChartLine extends LitElement {
private _chartTime: Date = new Date();
private _yAxisFractionDigits = 1;
private _previousYAxisLabelValue = 0;
private _yAxisMaximumFractionDigits = 0;
protected render() {
return html`
@@ -141,11 +141,12 @@ export class StateHistoryChartLine extends LitElement {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
);
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
@@ -176,44 +177,52 @@ export class StateHistoryChartLine extends LitElement {
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
color: dataset.color,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return html`${title}${datapoints.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? this.hass.localize("ui.components.history_charts.source_stats")
: this.hass.localize("ui.components.history_charts.source_history");
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
};
private _datasetHidden(ev: CustomEvent) {
@@ -404,7 +413,8 @@ export class StateHistoryChartLine extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: sideTooltipPosition,
position: "bottom",
align: "center",
confine: true,
formatter: this._renderTooltip,
},
@@ -426,14 +436,6 @@ export class StateHistoryChartLine extends LitElement {
const datasets: LineSeriesOption[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
if (entityStates.length === 0) {
return;
}
@@ -469,7 +471,6 @@ export class StateHistoryChartLine extends LitElement {
d.data!.push([timestamp, prevValues[i]]);
}
d.data!.push([timestamp, datavalues[i]]);
trackY(datavalues[i]);
});
prevValues = datavalues;
};
@@ -820,7 +821,6 @@ export class StateHistoryChartLine extends LitElement {
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([now, currentValue]);
trackY(currentValue);
}
}
@@ -828,7 +828,6 @@ export class StateHistoryChartLine extends LitElement {
Array.prototype.push.apply(datasets, data);
});
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
@@ -862,8 +861,20 @@ export class StateHistoryChartLine extends LitElement {
}
private _formatYAxisLabel = (value: number) => {
// show the first significant digit for tiny values
const maximumFractionDigits = Math.max(
1,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
);
this._yAxisMaximumFractionDigits = Math.max(
this._yAxisMaximumFractionDigits,
maximumFractionDigits
);
const label = formatNumber(value, this.hass.locale, {
maximumFractionDigits: this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisMaximumFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
@@ -873,6 +884,7 @@ export class StateHistoryChartLine extends LitElement {
chartIndex: this.chartIndex,
});
}
this._previousYAxisLabelValue = value;
return label;
};
@@ -1,10 +1,11 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -13,10 +14,8 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
@@ -57,7 +56,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
@@ -69,7 +68,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as HaECSeries}
.data=${this._chartData as ECOption["series"]}
small-controls
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
@@ -132,35 +131,42 @@ export class StateHistoryChartTimeline extends LitElement {
return rect;
};
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
const { value, name, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
)}<br />${formattedDuration}`;
};
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? marker
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
const lines = [
markerLocalized + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
public willUpdate(changedProps: PropertyValues) {
if (
@@ -257,7 +263,8 @@ export class StateHistoryChartTimeline extends LitElement {
},
tooltip: {
renderMode: "html",
position: sideTooltipPosition,
position: "bottom",
align: "center",
confine: true,
formatter: this._renderTooltip,
},
+12 -15
View File
@@ -2,13 +2,7 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
queryAll,
state,
} from "lit/decorators";
import { customElement, eventOptions, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@@ -110,11 +104,6 @@ export class StateHistoryCharts extends LitElement {
@state() private _hasZoomedCharts = false;
@queryAll("state-history-chart-line, state-history-chart-timeline")
private _chartComponents!: NodeListOf<
StateHistoryChartLine | StateHistoryChartTimeline
>;
private _isSyncing = false;
// @ts-ignore
@@ -167,7 +156,7 @@ export class StateHistoryCharts extends LitElement {
)}`}
${this.syncCharts && this._hasZoomedCharts
? html`<ha-button
size="l"
size="large"
class="reset-button"
@click=${this._handleGlobalZoomReset}
>
@@ -338,7 +327,11 @@ export class StateHistoryCharts extends LitElement {
this._isSyncing = true;
requestAnimationFrame(() => {
this._chartComponents.forEach((chartComponent, index) => {
const chartComponents = this.renderRoot.querySelectorAll(
"state-history-chart-line, state-history-chart-timeline"
) as unknown as (StateHistoryChartLine | StateHistoryChartTimeline)[];
chartComponents.forEach((chartComponent, index) => {
if (index === sourceChartIndex) {
return;
}
@@ -357,7 +350,11 @@ export class StateHistoryCharts extends LitElement {
this._isSyncing = true;
requestAnimationFrame(() => {
this._chartComponents.forEach((chartComponent: any) => {
const chartComponents = this.renderRoot.querySelectorAll(
"state-history-chart-line, state-history-chart-timeline"
);
chartComponents.forEach((chartComponent: any) => {
const chartBase =
chartComponent.renderRoot?.querySelector("ha-chart-base");
+100 -116
View File
@@ -4,7 +4,7 @@ import type {
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -34,15 +34,12 @@ import {
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -127,13 +124,13 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _hiddenStats = new Set<string>();
private _computedStyle?: CSSStyleDeclaration;
private _yAxisFractionDigits = 1;
private _previousYAxisLabelValue = 0;
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return changedProps.size > 1 || !changedProps.has("hass");
@@ -145,8 +142,7 @@ export class StatisticsChart extends LitElement {
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats") ||
changedProps.has("names")
changedProps.has("_hiddenStats")
) {
this._generateData();
}
@@ -252,101 +248,91 @@ export class StatisticsChart extends LitElement {
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
const rows: {
time?: string;
color: string;
seriesName?: string;
value: string;
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
rendered[param.seriesIndex] = true;
return params
.map((param, index: number) => {
if (rendered[param.seriesIndex]) return "";
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
}
} else {
startTime = new Date(param.value[0]);
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(endTime, this.hass.locale, this.hass.config)}`
: "");
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "");
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = formatDateTimeWithSeconds(
new Date(param.value[0]),
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
this.hass.locale,
this.hass.config
);
}
options
)}${unit}`;
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
rows.push({
time: rows.length === 0 ? rawTime : undefined,
color: String(param.color ?? ""),
seriesName: param.seriesName,
value,
});
}
if (rows.length === 0) return nothing;
return html`${rows.map(
(row, i) =>
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
)}`;
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
};
private _createOptions() {
@@ -473,7 +459,8 @@ export class StatisticsChart extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: sideTooltipPosition,
position: "bottom",
align: "center",
confine: true,
formatter: this._renderTooltip,
},
@@ -508,14 +495,6 @@ export class StatisticsChart extends LitElement {
const chartStacked = this.chartType.endsWith("stack");
const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: typeof this._chartData = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
const legendData: {
id: string;
name: string;
@@ -621,9 +600,6 @@ export class StatisticsChart extends LitElement {
d.data!.push([prevEndTime, null]);
}
d.data!.push([start, ...dataValues[i]!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValues[i][dataValues[i].length - 1]);
} else {
let time = start;
if (centerBars) {
@@ -634,7 +610,6 @@ export class StatisticsChart extends LitElement {
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
trackY(dataValues[i][0]);
}
});
prevValues = dataValues;
@@ -847,7 +822,6 @@ export class StatisticsChart extends LitElement {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
trackY(val[val.length - 1]);
});
}
}
@@ -881,7 +855,6 @@ export class StatisticsChart extends LitElement {
});
});
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = totalDataSets;
if (legendData.length !== this._legendData?.length) {
// only update the legend if it has changed or it will trigger options update
@@ -915,10 +888,21 @@ export class StatisticsChart extends LitElement {
return Math.abs(value) < 1 ? value : roundingFn(value);
}
private _formatYAxisLabel = (value: number) =>
formatNumber(value, this.hass.locale, {
maximumFractionDigits: this._yAxisFractionDigits,
private _formatYAxisLabel = (value: number) => {
// show the first significant digit for tiny values
const maximumFractionDigits = Math.max(
1,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
);
const label = formatNumber(value, this.hass.locale, {
maximumFractionDigits,
});
this._previousYAxisLabelValue = value;
return label;
};
static styles = css`
:host {
@@ -1,9 +0,0 @@
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range. We estimate the tick interval as `range / 10` (twice
// ECharts' default splitNumber of 5, as a safety margin against finer "nice"
// intervals), then derive `ceil(-log10(interval))`.
export function computeYAxisFractionDigits(min: number, max: number): number {
const range = max - min;
if (!Number.isFinite(range) || range <= 0) return 1;
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
}
@@ -1,14 +1,13 @@
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-dialog-footer";
import "../ha-icon-button";
@@ -25,9 +24,7 @@ import type { DataTableSettingsDialogParams } from "./show-dialog-data-table-set
@customElement("dialog-data-table-settings")
export class DialogDataTableSettings extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DataTableSettingsDialogParams;
@@ -120,7 +117,7 @@ export class DialogDataTableSettings extends LitElement {
return nothing;
}
const localize = this._params.localizeFunc || this._localize;
const localize = this._params.localizeFunc || this.hass.localize;
const columns = this._sortedColumns(
this._params.columns,
@@ -175,7 +172,7 @@ export class DialogDataTableSettings extends LitElement {
.hidden=${!isVisible}
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="meta"
.label=${localize(
.label=${this.hass!.localize(
`ui.components.data-table.settings.${isVisible ? "hide" : "show"}`,
{ title: typeof col.title === "string" ? col.title : "" }
)}
+31 -185
View File
@@ -1,8 +1,7 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare";
@@ -18,163 +17,40 @@ import "../ha-label";
class HaDataTableLabels extends LitElement {
@property({ attribute: false }) public labels!: LabelRegistryEntry[];
@state() private _visibleCount = 0;
@query(".viewport") private _viewport?: HTMLDivElement;
@query(".measure") private _measure?: HTMLDivElement;
private _sortedLabels: LabelRegistryEntry[] = [];
private _chipWidths: number[] = [];
private _plusWidth = 0;
private _gap = 8;
private _resizeController = new ResizeController(this, {
target: null,
skipInitial: true,
callback: (entries) => {
const entry = entries[0];
const width = entry?.contentRect.width ?? 0;
this._recomputeVisibleCount(width);
return width;
},
});
protected willUpdate(changedProps: Map<string, unknown>) {
if (changedProps.has("labels")) {
this._sortedLabels = [...this.labels].sort((a, b) =>
stringCompare(a.name, b.name)
);
}
}
protected render(): TemplateResult {
const labels = this._sortedLabels;
const visible = labels.slice(0, this._visibleCount);
const hidden = labels.length - this._visibleCount;
const labels = this.labels.sort((a, b) => stringCompare(a.name, b.name));
return html`
<div class="viewport">
<ha-chip-set>
${repeat(
visible,
(label) => label.label_id,
(label) => this._renderLabel(label, true)
)}
${hidden > 0
? html`
<ha-dropdown
role="button"
tabindex="0"
@click=${stopPropagation}
@wa-select=${this._handleDropdownSelect}
>
<ha-label slot="trigger" class="plus" dense>
+${hidden}
</ha-label>
${repeat(
labels.slice(this._visibleCount),
(label) => label.label_id,
(label) => html`
<ha-dropdown-item .value=${label.label_id} .item=${label}>
${this._renderLabel(label, false)}
</ha-dropdown-item>
`
)}
</ha-dropdown>
`
: nothing}
</ha-chip-set>
</div>
<div class="measure" aria-hidden="true">
<ha-chip-set>
${repeat(
labels,
(label) => label.label_id,
(label) => html`
<div class="measure-chip" data-chip>
${this._renderLabel(label, false)}
</div>
`
)}
<div class="measure-chip" data-plus>
<ha-label class="plus" dense>+99</ha-label>
</div>
</ha-chip-set>
</div>
<ha-chip-set>
${repeat(
labels.slice(0, 2),
(label) => label.label_id,
(label) => this._renderLabel(label, true)
)}
${labels.length > 2
? html`<ha-dropdown
role="button"
tabindex="0"
@click=${stopPropagation}
@wa-select=${this._handleDropdownSelect}
>
<ha-label slot="trigger" class="plus" dense>
+${labels.length - 2}
</ha-label>
${repeat(
labels.slice(2),
(label) => label.label_id,
(label) => html`
<ha-dropdown-item .value=${label.label_id} .item=${label}>
${this._renderLabel(label, false)}
</ha-dropdown-item>
`
)}
</ha-dropdown>`
: nothing}
</ha-chip-set>
`;
}
protected async firstUpdated() {
await this.updateComplete;
if (this._viewport) {
this._resizeController.observe(this._viewport);
}
await this._measureWidths();
this._recomputeVisibleCount(this._viewport?.clientWidth ?? 0);
}
protected async updated(changedProps: Map<string, unknown>) {
if (changedProps.has("labels")) {
await this.updateComplete;
await this._measureWidths();
this._recomputeVisibleCount(this._viewport?.clientWidth ?? 0);
}
}
private async _measureWidths() {
await this.updateComplete;
const measureRoot = this._measure;
if (!measureRoot) {
return;
}
const measureChipSet = measureRoot.querySelector("ha-chip-set");
if (measureChipSet) {
const styles = getComputedStyle(measureChipSet);
const raw = styles.columnGap || styles.gap;
this._gap = raw ? parseFloat(raw) : 0;
}
const chipEls = Array.from(
measureRoot.querySelectorAll<HTMLElement>("[data-chip]")
);
const plusEl = measureRoot.querySelector<HTMLElement>("[data-plus]");
this._chipWidths = chipEls.map((el) => el.offsetWidth);
this._plusWidth = plusEl?.offsetWidth ?? 0;
}
private _recomputeVisibleCount(containerWidth: number) {
if (!containerWidth || !this.labels?.length) {
this._visibleCount = 0;
return;
}
const total = this._sortedLabels.length;
let used = 0;
let visibleCount = 0;
for (let i = 0; i < total; i++) {
const chipWidth = this._chipWidths[i] ?? 0;
const nextUsed =
visibleCount === 0 ? chipWidth : used + this._gap + chipWidth;
const remaining = total - (i + 1);
const reserve = remaining > 0 ? this._gap + this._plusWidth : 0;
if (nextUsed + reserve <= containerWidth) {
used = nextUsed;
visibleCount++;
} else {
break;
}
}
this._visibleCount = visibleCount;
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
return html`
<ha-label
@@ -217,43 +93,13 @@ class HaDataTableLabels extends LitElement {
:host {
display: block;
flex-grow: 1;
min-width: 0;
margin-top: 4px;
height: 22px;
position: relative;
}
.viewport {
min-width: 0;
width: 100%;
overflow: hidden;
}
ha-chip-set {
display: flex;
position: fixed;
flex-wrap: nowrap;
align-items: center;
overflow: hidden;
min-width: 0;
}
.measure {
position: absolute;
inset: 0 auto auto 0;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
}
.measure ha-chip-set {
width: max-content;
overflow: visible;
}
.measure-chip {
display: inline-flex;
}
.plus {
--ha-label-background-color: transparent;
border: 1px solid var(--divider-color);
@@ -24,7 +24,6 @@ import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-textarea";
import type { HaTextArea } from "../ha-textarea";
import "./date-range-picker";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
@@ -99,8 +98,6 @@ export class HaDateRangePicker extends LitElement {
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-textarea") private _textareaElement?: HaTextArea;
private _narrow = false;
private _unsubscribeTinyKeys?: () => void;
@@ -338,8 +335,9 @@ export class HaDateRangePicker extends LitElement {
};
private _setTextareaFocusStyle(focused: boolean) {
if (this._textareaElement) {
this._textareaElement.setFocused(focused);
const textarea = this.renderRoot.querySelector("ha-textarea");
if (textarea) {
textarea.setFocused(focused);
}
}
@@ -1,12 +1,10 @@
import { consume } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
@@ -14,7 +12,7 @@ import {
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerValueRenderer } from "../ha-picker-field";
@@ -48,14 +46,13 @@ export abstract class HaDeviceAutomationPicker<
}
private _localizeDeviceAutomation: (
localize: LocalizeFunc,
states: HassEntities,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
automation: T
) => string;
private _fetchDeviceAutomations: (
callWS: CallWS,
hass: HomeAssistant,
deviceId: string
) => Promise<T[]>;
@@ -130,8 +127,7 @@ export abstract class HaDeviceAutomationPicker<
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this.hass,
this._entityReg,
automation
);
@@ -166,12 +162,7 @@ export abstract class HaDeviceAutomationPicker<
);
const text = automation
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
automation
)
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
: value;
@@ -181,9 +172,9 @@ export abstract class HaDeviceAutomationPicker<
private async _updateDeviceInfo() {
this._automations = this.deviceId
? (
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
).sort(sortDeviceAutomations)
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
sortDeviceAutomations
)
: // No device, clear the list of automations
[];
+5 -3
View File
@@ -107,15 +107,17 @@ export class HaDevicePicker extends LitElement {
excludeDevices?: string[],
value?: string
) =>
getDevices(this.hass, configEntryLookup, {
getDevices(
this.hass,
configEntryLookup,
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value,
})
value
)
);
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
+7 -18
View File
@@ -2,17 +2,12 @@ import { mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
type HASSDomEvent,
} from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-sortable";
import "./ha-entity-picker";
import type { HaEntityPicker } from "./ha-entity-picker";
@customElement("ha-entities-picker")
class HaEntitiesPicker extends LitElement {
@@ -156,7 +151,7 @@ class HaEntitiesPicker extends LitElement {
`;
}
private _entityMoved(e: HASSDomEvent<HASSDomEvents["item-moved"]>) {
private _entityMoved(e: CustomEvent) {
e.stopPropagation();
const { oldIndex, newIndex } = e.detail;
const currentEntities = this._currentEntities;
@@ -183,7 +178,7 @@ class HaEntitiesPicker extends LitElement {
return this.value || [];
}
private async _updateEntities(entities: string[]) {
private async _updateEntities(entities) {
this.value = entities;
fireEvent(this, "value-changed", {
@@ -191,12 +186,9 @@ class HaEntitiesPicker extends LitElement {
});
}
private _entityChanged(
event: ValueChangedEvent<string> &
HASSDomCurrentTargetEvent<HaEntityPicker & { curValue: string }>
) {
private _entityChanged(event: ValueChangedEvent<string>) {
event.stopPropagation();
const curValue = event.currentTarget.curValue;
const curValue = (event.currentTarget as any).curValue;
const newValue = event.detail.value;
if (
newValue === curValue ||
@@ -214,15 +206,13 @@ class HaEntitiesPicker extends LitElement {
);
}
private _addEntity(
event: ValueChangedEvent<string> & HASSDomCurrentTargetEvent<HaEntityPicker>
) {
private async _addEntity(event: ValueChangedEvent<string>) {
event.stopPropagation();
const toAdd = event.detail.value;
if (!toAdd) {
return;
}
event.currentTarget.value = "";
(event.currentTarget as any).value = "";
if (!toAdd) {
return;
}
@@ -249,7 +239,6 @@ class HaEntitiesPicker extends LitElement {
}
.entity ha-entity-picker {
flex: 1;
min-width: var(--ha-entities-picker-entity-min-width, auto);
}
.entity-handle {
padding: 8px;
@@ -117,7 +117,7 @@ export class HaEntityNamePicker extends LitElement {
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-button-toggle-group
size="s"
size="small"
.buttons=${modeButtons}
.active=${this._mode}
.disabled=${this.disabled}
+1 -23
View File
@@ -309,29 +309,7 @@ export class HaEntityPicker extends LitElement {
}
);
private _getEntitiesMemoized = memoizeOne(
(
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
includeDeviceClasses?: string[],
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string
) =>
getEntities(hass, {
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
})
);
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getItems = () => {
const items = this._getEntitiesMemoized(
+6 -3
View File
@@ -6,7 +6,11 @@ import { customElement, property, state } from "lit/decorators";
import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import {
UNAVAILABLE,
UNKNOWN,
isUnavailableState,
} from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
@@ -16,8 +20,7 @@ import "../ha-switch";
const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined &&
!STATES_OFF.includes(stateObj.state) &&
stateObj.state !== UNAVAILABLE &&
stateObj.state !== UNKNOWN;
!isUnavailableState(stateObj.state);
/**
* @element ha-entity-toggle
@@ -9,7 +9,7 @@ import secondsToDuration from "../../common/datetime/seconds_to_duration";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types";
@@ -170,8 +170,7 @@ export class HaStateLabelBadge extends LitElement {
}
// eslint-disable-next-line: disable=no-fallthrough
default:
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
return isUnavailableState(entityState.state)
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
@@ -210,7 +209,7 @@ export class HaStateLabelBadge extends LitElement {
_timerTimeRemaining = 0
) {
// For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
if (isUnavailableState(entityState.state)) {
return this.hass!.localize(`state_badge.default.${entityState.state}`);
}
const domainStateKey = getTruncatedKey(domain, entityState.state);
+4 -43
View File
@@ -1,16 +1,11 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import {
mdiChartLine,
mdiHelpCircleOutline,
mdiPencil,
mdiShape,
} from "@mdi/js";
import { mdiChartLine, mdiHelpCircleOutline, mdiShape } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -58,16 +53,6 @@ const SEARCH_KEYS = [
{ name: "id", weight: 2 },
];
export interface StatisticElementChangedEvent {
statisticId: string;
}
declare global {
interface HASSDomEvents {
"edit-statistics-element": StatisticElementChangedEvent;
}
}
@customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -145,8 +130,6 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && !this.statisticIds) ||
@@ -159,7 +142,6 @@ export class HaStatisticPicker extends LitElement {
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
this._picker?.requestUpdate();
this._valueRenderer = this._makeValueRenderer();
}
private _getItems = () =>
@@ -335,7 +317,7 @@ export class HaStatisticPicker extends LitElement {
}
);
private _renderValue(value: string) {
private _valueRenderer: PickerValueRenderer = (value) => {
const statisticId = value;
const item = this._computeItem(statisticId);
@@ -358,29 +340,8 @@ export class HaStatisticPicker extends LitElement {
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${this.canEdit
? html`<ha-icon-button
slot="end"
.value=${statisticId}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>`
: nothing}
`;
}
private _makeValueRenderer(): PickerValueRenderer {
return (value) => this._renderValue(value);
}
private _valueRenderer: PickerValueRenderer = this._makeValueRenderer();
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
ev.stopPropagation();
const statisticId = (ev.currentTarget as any).value;
fireEvent(this, "edit-statistics-element", { statisticId });
}
};
private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId];
+1 -17
View File
@@ -1,10 +1,9 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { ValueChangedEvent, HomeAssistant } from "../../types";
import "./ha-statistic-picker";
import type { StatisticElementChangedEvent } from "./ha-statistic-picker";
@customElement("ha-statistics-picker")
class HaStatisticsPicker extends LitElement {
@@ -60,8 +59,6 @@ class HaStatisticsPicker extends LitElement {
})
public ignoreRestrictionsOnFirstStatistic = false;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?;
protected render() {
if (!this.hass) {
return nothing;
@@ -102,9 +99,7 @@ class HaStatisticsPicker extends LitElement {
.statisticIds=${this.statisticIds}
.excludeStatistics=${this.value}
.allowCustomEntity=${this.allowCustomEntity}
.canEdit=${this.canEdit}
@value-changed=${this._statisticChanged}
@edit-statistics-element=${this._editItem}
></ha-statistic-picker>
</div>
`
@@ -127,17 +122,6 @@ class HaStatisticsPicker extends LitElement {
`;
}
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
const statisticId = ev.detail.statisticId;
const index = this._currentStatistics!.findIndex((e) => e === statisticId);
fireEvent(this, "edit-detail-element", {
subElementConfig: {
index,
type: "row",
},
});
}
private get _currentStatistics() {
return this.value || [];
}
+3
View File
@@ -43,6 +43,7 @@ class StateInfo extends LitElement {
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
@@ -54,6 +55,7 @@ class StateInfo extends LitElement {
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_updated}
capitalize
></ha-relative-time>
@@ -61,6 +63,7 @@ class StateInfo extends LitElement {
</ha-tooltip>
<ha-relative-time
id="relative-time"
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
+7 -27
View File
@@ -1,34 +1,18 @@
import { consume } from "@lit/context";
import { addDays, differenceInMilliseconds, startOfDay } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../common/decorators/transform";
import { customElement, property } from "lit/decorators";
import { absoluteTime } from "../common/datetime/absolute_time";
import { configContext, internationalizationContext } from "../data/context";
import type {
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../types";
import type { HomeAssistant } from "../types";
const SAFE_MARGIN = 5 * 1000;
@customElement("ha-absolute-time")
class HaAbsoluteTime extends ReactiveElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public datetime?: string | Date;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config?: HassConfig;
private _timeout?: number;
public disconnectedCallback(): void {
@@ -78,17 +62,13 @@ class HaAbsoluteTime extends ReactiveElement {
}
private _updateAbsolute(): void {
if (!this._i18n || !this._config) {
return;
}
if (!this.datetime) {
this.innerHTML = this._i18n.localize("ui.components.absolute_time.never");
this.innerHTML = this.hass.localize("ui.components.absolute_time.never");
} else {
this.innerHTML = absoluteTime(
new Date(this.datetime),
this._i18n.locale,
this._config
this.hass.locale,
this.hass.config
);
}
}
+3 -5
View File
@@ -1,6 +1,6 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { ScrollLockMixin } from "../mixins/scroll-lock-mixin";
@@ -25,8 +25,6 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
@state() private _shouldRenderPopover = false;
@query("wa-popover") private _popoverElement?: HTMLElement;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
changedProperties.has("dialogAnchor") ||
@@ -190,7 +188,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
}
private _handlePopoverPointerDown(ev: PointerEvent) {
const popover = this._popoverElement;
const popover = this.renderRoot.querySelector("wa-popover");
const dialog = popover?.shadowRoot?.querySelector(
"dialog"
) as HTMLDialogElement | null;
@@ -217,7 +215,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
}
private _pulsePopover() {
const popover = this._popoverElement;
const popover = this.renderRoot.querySelector("wa-popover");
const popup = popover?.shadowRoot?.querySelector("wa-popup") as {
popup?: HTMLElement;
} | null;
+3 -6
View File
@@ -6,9 +6,8 @@ import {
mdiInformationOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
@@ -40,9 +39,7 @@ class HaAlert extends LitElement {
@property({ type: Boolean }) public dismissable = false;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ type: Boolean }) public narrow = false;
@@ -71,7 +68,7 @@ class HaAlert extends LitElement {
${this.dismissable
? html`<ha-icon-button
@click=${this._dismissClicked}
.label=${this._localize?.("ui.common.dismiss_alert")}
.label=${this.localize!("ui.common.dismiss_alert")}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
+12 -10
View File
@@ -5,10 +5,10 @@ import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
import { haStyle } from "../resources/styles";
import "./ha-md-list-item";
import "./ha-switch";
import type { HaSwitch } from "./ha-switch";
import "./ha-tooltip";
import "./item/ha-row-item";
import type { HaSwitch } from "./ha-switch";
const ADDITIONAL_PREFERENCES = ["usage", "statistics"] as const;
@@ -33,7 +33,7 @@ export class HaAnalytics extends LitElement {
const baseEnabled = !loading && this.analytics!.preferences.base;
return html`
<ha-row-item>
<ha-md-list-item>
<span slot="headline"
>${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
@@ -52,10 +52,10 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="base"
></ha-switch>
</ha-row-item>
</ha-md-list-item>
${ADDITIONAL_PREFERENCES.map(
(preference) => html`
<ha-row-item>
<ha-md-list-item>
<span slot="headline"
>${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
@@ -81,10 +81,10 @@ export class HaAnalytics extends LitElement {
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>`}
</ha-row-item>
</ha-md-list-item>
`
)}
<ha-row-item>
<ha-md-list-item>
<span slot="headline"
>${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
@@ -103,7 +103,7 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="diagnostics"
></ha-switch>
</ha-row-item>
</ha-md-list-item>
`;
}
@@ -139,8 +139,10 @@ export class HaAnalytics extends LitElement {
color: var(--error-color);
}
ha-row-item {
--ha-row-item-padding-inline: 0;
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
`,
];
+1 -4
View File
@@ -9,7 +9,6 @@ import {
customElement,
property,
query,
queryAll,
state as litState,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -32,8 +31,6 @@ export class HaAnsiToHtml extends LitElement {
@query("pre") private _pre?: HTMLPreElement;
@queryAll("div") private _divs!: NodeListOf<HTMLDivElement>;
@litState() private _filter = "";
protected render(): TemplateResult {
@@ -323,7 +320,7 @@ export class HaAnsiToHtml extends LitElement {
*/
filterLines(filter: string): boolean {
this._filter = filter;
const lines = this._divs;
const lines = this.shadowRoot?.querySelectorAll("div") || [];
let numberOfFoundLines = 0;
if (!filter) {
lines.forEach((line) => {
+3 -3
View File
@@ -29,7 +29,7 @@ export interface AreaControlPickerItem extends PickerComboBoxItem {
deviceClass?: string;
}
const AREA_CONTROL_DOMAINS = [
const AREA_CONTROL_DOMAINS: readonly AreaControlDomain[] = [
"light",
"fan",
"switch",
@@ -43,7 +43,7 @@ const AREA_CONTROL_DOMAINS = [
"cover-door",
"cover-window",
"cover-damper",
] as const satisfies readonly AreaControlDomain[];
] as const;
@customElement("ha-area-controls-picker")
export class HaAreaControlsPicker extends LitElement {
@@ -130,7 +130,7 @@ export class HaAreaControlsPicker extends LitElement {
(excludeValues !== undefined && excludeValues.includes(id));
const controlEntities = getAreaControlEntities(
AREA_CONTROL_DOMAINS,
AREA_CONTROL_DOMAINS as unknown as AreaControlDomain[],
areaId,
excludeEntities,
this.hass
+32 -85
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html, nothing } from "lit";
@@ -11,19 +10,9 @@ import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { areaComboBoxKeys, getAreas } from "../data/area/area_picker";
import { createAreaRegistryEntry } from "../data/area/area_registry";
import {
apiContext,
areasContext,
devicesContext,
entitiesContext,
floorsContext,
internationalizationContext,
statesContext,
} from "../data/context";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import type { ValueChangedEvent } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box-item";
import "./ha-generic-picker";
@@ -37,6 +26,8 @@ const ADD_NEW_ID = "___ADD_NEW___";
@customElement("ha-area-picker")
export class HaAreaPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@@ -92,37 +83,16 @@ export class HaAreaPicker extends LitElement {
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
private _floors!: ContextType<typeof floorsContext>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _pendingAreaId?: string;
protected willUpdate(changedProperties: PropertyValues) {
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingAreaId &&
changedProperties.has("_areas") &&
this._areas[this._pendingAreaId]
changedProperties.has("hass") &&
this.hass.areas !== changedProperties.get("hass")?.areas &&
this.hass.areas[this._pendingAreaId]
) {
this._setValue(this._pendingAreaId);
this._pendingAreaId = undefined;
@@ -134,35 +104,13 @@ export class HaAreaPicker extends LitElement {
await this._picker?.open();
}
private _getAreasMemoized = memoizeOne(
(
haAreas: ContextType<typeof areasContext>,
haFloors: ContextType<typeof floorsContext>,
haDevices: ContextType<typeof devicesContext>,
haEntities: ContextType<typeof entitiesContext>,
haStates: ContextType<typeof statesContext>,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[]
) =>
getAreas(haAreas, haFloors, haDevices, haEntities, haStates, {
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeAreas,
})
);
private _getAreasMemoized = memoizeOne(getAreas);
// Recompute value renderer when the areas change
private _computeValueRenderer = memoizeOne(
(haAreas: ContextType<typeof areasContext>): PickerValueRenderer =>
(_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
(value) => {
const area = haAreas[value];
const area = this.hass.areas[value];
if (!area) {
return html`
@@ -171,7 +119,7 @@ export class HaAreaPicker extends LitElement {
`;
}
const { floor } = getAreaContext(area, this._floors);
const { floor } = getAreaContext(area, this.hass.floors);
const areaName = area ? computeAreaName(area) : undefined;
const floorName = floor ? computeFloorName(floor) : undefined;
@@ -195,11 +143,11 @@ export class HaAreaPicker extends LitElement {
private _getItems = () =>
this._getAreasMemoized(
this._areas,
this._floors,
this._devices,
this._entities,
this._states,
this.hass.areas,
this.hass.floors,
this.hass.devices,
this.hass.entities,
this.hass.states,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
@@ -209,7 +157,7 @@ export class HaAreaPicker extends LitElement {
);
private _allAreaNames = memoizeOne(
(areas: ContextType<typeof areasContext>) =>
(areas: HomeAssistant["areas"]) =>
Object.values(areas)
.map((area) => computeAreaName(area)?.toLowerCase())
.filter(Boolean) as string[]
@@ -222,13 +170,13 @@ export class HaAreaPicker extends LitElement {
return [];
}
const allAreas = this._allAreaNames(this._areas);
const allAreas = this._allAreaNames(this.hass.areas);
if (searchString && !allAreas.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this._i18n.localize(
primary: this.hass.localize(
"ui.components.area-picker.add_new_suggestion",
{
name: searchString,
@@ -242,7 +190,7 @@ export class HaAreaPicker extends LitElement {
return [
{
id: ADD_NEW_ID,
primary: this._i18n.localize("ui.components.area-picker.add_new"),
primary: this.hass.localize("ui.components.area-picker.add_new"),
icon_path: mdiPlus,
},
];
@@ -250,17 +198,15 @@ export class HaAreaPicker extends LitElement {
protected render(): TemplateResult {
const baseLabel =
this.label ?? this._i18n.localize("ui.components.area-picker.area");
const areas = this._areas;
const floors = this._floors;
const valueRenderer = this._computeValueRenderer(areas);
this.label ?? this.hass.localize("ui.components.area-picker.area");
const valueRenderer = this._computeValueRenderer(this.hass.areas);
// Only show label if there's no floor
let label: string | undefined = baseLabel;
if (this.value && baseLabel) {
const area = areas[this.value];
const area = this.hass.areas[this.value];
if (area) {
const { floor } = getAreaContext(area, floors);
const { floor } = getAreaContext(area, this.hass.floors);
if (floor) {
label = undefined;
}
@@ -269,11 +215,12 @@ export class HaAreaPicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${label}
.helper=${this.helper}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this._i18n.localize("ui.components.area-picker.no_areas")}
.emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")}
.disabled=${this.disabled}
.required=${this.required}
.value=${this.value}
@@ -282,7 +229,7 @@ export class HaAreaPicker extends LitElement {
.valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel}
.searchKeys=${areaComboBoxKeys}
.unknownItemText=${this._i18n.localize(
.unknownItemText=${this.hass.localize(
"ui.components.area-picker.unknown"
)}
@value-changed=${this._valueChanged}
@@ -301,7 +248,7 @@ export class HaAreaPicker extends LitElement {
}
if (value.startsWith(ADD_NEW_ID)) {
this._i18n.loadFragmentTranslation("config");
this.hass.loadFragmentTranslation("config");
const suggestedName = value.substring(ADD_NEW_ID.length);
@@ -309,15 +256,15 @@ export class HaAreaPicker extends LitElement {
suggestedName: suggestedName,
createEntry: async (values) => {
try {
const area = await createAreaRegistryEntry(this._api, values);
if (this._areas[area.area_id]) {
const area = await createAreaRegistryEntry(this.hass, values);
if (this.hass.areas[area.area_id]) {
this._setValue(area.area_id);
} else {
this._pendingAreaId = area.area_id;
}
} catch (err: any) {
showAlertDialog(this, {
title: this._i18n.localize(
title: this.hass.localize(
"ui.components.area-picker.failed_create_area"
),
text: err.message,
@@ -338,7 +285,7 @@ export class HaAreaPicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this._i18n.localize("ui.components.area-picker.no_match", {
this.hass.localize("ui.components.area-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
@@ -61,6 +61,7 @@ export class HaAreasDisplayEditor extends LitElement {
>
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon>
<ha-items-display-editor
.hass=${this.hass}
.items=${items}
.value=${value}
@value-changed=${this._areaDisplayChanged}
@@ -107,6 +107,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
></ha-svg-icon>
`}
<ha-items-display-editor
.hass=${this.hass}
.items=${groupedAreasItems[floor.floor_id]}
.value=${value}
.floorId=${floor.floor_id}
+2
View File
@@ -85,6 +85,7 @@ export class HaAreasPicker extends SubscribeMixin(LitElement) {
<ha-area-picker
.curValue=${area}
.noAdd=${this.noAdd}
.hass=${this.hass}
.value=${area}
.label=${this.pickedAreaLabel}
.includeDomains=${this.includeDomains}
@@ -111,6 +112,7 @@ export class HaAreasPicker extends SubscribeMixin(LitElement) {
<div>
<ha-area-picker
.noAdd=${this.noAdd}
.hass=${this.hass}
.label=${this.pickAreaLabel}
.helper=${this.helper}
.includeDomains=${this.includeDomains}
+4 -5
View File
@@ -1,6 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
@@ -24,12 +24,11 @@ class HaBluePrintPicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
@query("ha-select") private _select?: HTMLElement;
public open() {
if (this._select) {
const select = this.shadowRoot?.querySelector("ha-select");
if (select) {
// @ts-expect-error
this._select.menuOpen = true;
select.menuOpen = true;
}
}
+5 -5
View File
@@ -75,8 +75,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@query("#body") private _bodyElement!: HTMLDivElement;
@query("[autofocus]") private _autofocusElement?: HTMLElement;
protected get scrollableElement(): HTMLElement | null {
return this._bodyElement;
}
@@ -95,12 +93,12 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
const element = this._autofocusElement;
if (
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
if (element) {
const element = this.renderRoot.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-bottom-sheet-autofocus";
}
@@ -113,7 +111,9 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
}
return;
}
element?.focus();
(
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
)?.focus();
});
};
+2 -2
View File
@@ -15,7 +15,7 @@ import "./ha-svg-icon";
*
* @attr {ToggleButton[]} buttons - the button config
* @attr {string} active - The value of the currently active button.
* @attr {("s"|"m")} size - The size of the buttons in the group.
* @attr {("small"|"medium")} size - The size of the buttons in the group.
* @attr {("brand"|"neutral"|"success"|"warning"|"danger")} variant - The variant of the buttons in the group.
*
* @fires value-changed - Dispatched when the active button changes.
@@ -26,7 +26,7 @@ export class HaButtonToggleGroup extends LitElement {
@property() public active?: string;
@property({ reflect: true }) size: "s" | "m" = "m";
@property({ reflect: true }) size: "small" | "medium" = "medium";
@property({ type: Boolean, reflect: true, attribute: "no-wrap" })
public nowrap = false;
+3 -3
View File
@@ -27,7 +27,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
* @cssprop --ha-button-height - The height of the button.
* @cssprop --ha-button-border-radius - The border radius of the button. defaults to `var(--ha-border-radius-pill)`.
*
* @attr {("xs"|"s"|"m"|"l"|"xl")} size - Sets the button size.
* @attr {("small"|"medium"|"large")} size - Sets the button size.
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
@@ -65,7 +65,7 @@ export class HaButton extends Button {
box-shadow: var(--ha-button-box-shadow);
}
:host([size="s"]) .button {
:host([size="small"]) .button {
--wa-form-control-height: var(
--ha-button-height,
var(--button-height, 32px)
@@ -74,7 +74,7 @@ export class HaButton extends Button {
--wa-form-control-padding-inline: var(--ha-space-3);
}
:host([size="l"]) .button {
:host([size="large"]) .button {
--wa-form-control-height: var(
--ha-button-height,
var(--button-height, 48px)
-1
View File
@@ -20,7 +20,6 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
+4 -9
View File
@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ClimateEntity } from "../data/climate";
import { CLIMATE_PRESET_NONE } from "../data/climate";
import { OFF, UNAVAILABLE, UNKNOWN } from "../data/entity/entity";
import { isUnavailableState, OFF } from "../data/entity/entity";
import type { HomeAssistant } from "../types";
@customElement("ha-climate-state")
@@ -14,11 +14,9 @@ class HaClimateState extends LitElement {
protected render(): TemplateResult {
const currentStatus = this._computeCurrentStatus();
const noValue =
this.stateObj.state === UNAVAILABLE || this.stateObj.state === UNKNOWN;
return html`<div class="target">
${!noValue
${!isUnavailableState(this.stateObj.state)
? html`<span class="state-label">
${this._localizeState()}
${this.stateObj.attributes.preset_mode &&
@@ -34,7 +32,7 @@ class HaClimateState extends LitElement {
: this._localizeState()}
</div>
${currentStatus && !noValue
${currentStatus && !isUnavailableState(this.stateObj.state)
? html`
<div class="current">
${this.hass.localize("ui.card.climate.currently")}:
@@ -121,10 +119,7 @@ class HaClimateState extends LitElement {
}
private _localizeState(): string {
if (
this.stateObj.state === UNAVAILABLE ||
this.stateObj.state === UNKNOWN
) {
if (isUnavailableState(this.stateObj.state)) {
return this.hass.localize(`state.default.${this.stateObj.state}`);
}

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