Compare commits

..

23 Commits

Author SHA1 Message Date
Bram Kragten f40ddceadc test(e2e): handle skip return from waitForLocator in all sidebar wait calls 2026-05-27 10:27:42 +02:00
Bram Kragten de9a6d82de test(e2e): skip on both dynamic-import errors and BrowserStack iOS Internal error 2026-05-27 10:27:42 +02:00
Bram Kragten b691ebe336 test(e2e): revert to locator.waitFor with BS iOS Internal error catch-and-skip 2026-05-27 10:27:42 +02:00
Bram Kragten 79347ecc34 test(e2e): fix waitForShadow helper to use recursive shadow DOM traversal 2026-05-27 10:27:42 +02:00
Bram Kragten 01340c5e03 test(e2e): replace waitForSelector with evaluate polling in sidebar test for iOS 2026-05-27 10:27:42 +02:00
Bram Kragten f3abf60a19 test(e2e): use evaluate for card visibility check on iOS where locator can't pierce shadow DOM 2026-05-27 10:27:42 +02:00
Bram Kragten 748f616d15 test(e2e): fix evaluate argument serialization on BrowserStack iOS 2026-05-27 10:27:42 +02:00
Bram Kragten b4c88520f2 test(e2e): use recursive shadow DOM evaluate poll instead of waitForSelector on iOS 2026-05-27 10:27:41 +02:00
Bram Kragten ecada55a33 test(e2e): skip cards/dialog checks when dynamic imports fail over tunnel 2026-05-27 10:27:41 +02:00
Bram Kragten f443c0ba74 test(e2e): replace class-wildcard selector with explicit hui-* tag selectors for iPhone 2026-05-27 10:27:41 +02:00
Bram Kragten 30414703c1 test(e2e): fix WebKit dynamic-import filter and avoid concurrent waitFor on mobile 2026-05-27 10:27:41 +02:00
Bram Kragten e0e93e0fa9 test(e2e): reuse pre-existing BrowserStack page; filter infra dynamic-import errors 2026-05-27 10:27:41 +02:00
Bram Kragten b7bdad3d21 test(e2e): use serial mode + shared page to fix BrowserStack iPhone context limit 2026-05-27 10:27:41 +02:00
Bram Kragten bc55bc727d Use browserstack node SDK instead 2026-05-27 10:27:41 +02:00
Bram Kragten 7ec5f157c3 use bs-local.com for browserstack 2026-05-27 10:27:23 +02:00
Bram Kragten c1a21dff29 Update browserstack.capabilities.ts 2026-05-27 10:27:23 +02:00
Bram Kragten 2d19d40277 fix browserstack ios tests 2026-05-27 10:27:23 +02:00
Bram Kragten 73a028fa1a fix gallery tests 2026-05-27 10:27:23 +02:00
Bram Kragten 78074f7305 fix 2026-05-27 10:27:23 +02:00
Bram Kragten 1428a5b14e fix browserstack tests 2026-05-27 10:27:23 +02:00
Bram Kragten abd438fc47 fixes 2026-05-27 10:27:23 +02:00
Bram Kragten 27e2dc91ad Update e2e.yaml 2026-05-27 10:27:23 +02:00
Bram Kragten da9ce03987 Add e2e playwright tests 2026-05-27 10:27:23 +02:00
517 changed files with 11442 additions and 9355 deletions
+64 -31
View File
@@ -40,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
@@ -58,7 +58,7 @@ The Home Assistant frontend is a modern web application that:
**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
@@ -160,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
@@ -267,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`**: `"small"` (32px), `"medium"` (40px - default), `"large"` (48px)
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:**
@@ -315,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
@@ -457,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
@@ -548,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
@@ -577,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
@@ -593,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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
+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 }}
+276
View File
@@ -0,0 +1,276 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs: [e2e-local, e2e-browserstack]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+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: ""
+1 -1
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
+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
+7
View File
@@ -54,7 +54,14 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
+53
View File
@@ -0,0 +1,53 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+18 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -321,4 +320,22 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
+4
View File
@@ -1,9 +1,13 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,4 +1,3 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+7
View File
@@ -45,3 +45,10 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
+41
View File
@@ -0,0 +1,41 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+21
View File
@@ -268,3 +268,24 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+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`),
+20
View File
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
+1
View File
@@ -4,6 +4,7 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+20
View File
@@ -14,6 +14,7 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -210,3 +211,22 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
+11
View File
@@ -50,4 +50,15 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+6 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -345,4 +349,5 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockCloud = (hass: MockHomeAssistant) => {
// REST mock for cloud status — returns disconnected so config panel loads
// without errors but without requiring cloud integration.
hass.mockAPI("cloud/status", () => ({
logged_in: false,
cloud: "disconnected",
prefs: {
google_enabled: false,
alexa_enabled: false,
cloudhooks: {},
remote_enabled: false,
},
google_registered: false,
alexa_registered: false,
remote_domain: null,
remote_connected: false,
remote_certificate: null,
}));
};
+5
View File
@@ -0,0 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
+6
View File
@@ -228,6 +228,12 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+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"
-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;
}
`,
];
}
+44 -29
View File
@@ -21,13 +21,22 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"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",
@@ -40,15 +49,15 @@
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@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-datetimeformat": "7.4.6",
"@formatjs/intl-displaynames": "7.3.8",
"@formatjs/intl-durationformat": "0.10.12",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.9",
"@formatjs/intl-listformat": "8.3.8",
"@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-numberformat": "9.3.9",
"@formatjs/intl-pluralrules": "6.3.8",
"@formatjs/intl-relativetimeformat": "12.3.8",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -62,16 +71,17 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@material/mwc-base": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/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.1",
"@tsparticles/preset-links": "4.1.1",
"@tsparticles/engine": "4.0.5",
"@tsparticles/preset-links": "4.0.5",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -82,20 +92,20 @@
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.4.0",
"date-fns": "4.3.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.0",
"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",
"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",
@@ -125,20 +135,22 @@
"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",
"@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",
"@playwright/test": "1.59.1",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.4",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -150,15 +162,18 @@
"@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.7",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"del": "8.0.1",
"eslint": "10.4.1",
"eslint": "10.4.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -179,8 +194,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.5",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -191,12 +206,12 @@
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.15",
"terser-webpack-plugin": "5.6.1",
"terser-webpack-plugin": "5.6.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.60.0",
"typescript-eslint": "8.59.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"vitest": "4.1.7",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"

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"
+19 -67
View File
@@ -11,7 +11,6 @@ import {
} 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 +26,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 +63,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
-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;
});
@@ -33,32 +33,30 @@ export class HaAutomationRowLiveTest extends LitElement {
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 10px;
height: 10px;
width: 12px;
height: 12px;
border-radius: var(--ha-border-radius-circle);
border: var(--ha-border-width-md) solid;
border: 3px 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);
background-color: var(--ha-color-fill-success-loud-resting);
border-color: var(--ha-color-fill-success-loud-resting);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-orange-60);
border-color: var(--ha-color-fill-warning-loud-resting);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-red-60);
border-color: var(--ha-color-fill-danger-loud-resting);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-neutral-60);
border-color: var(--ha-color-fill-neutral-loud-resting);
}
`;
}
@@ -165,8 +165,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);
+47 -82
View File
@@ -14,7 +14,6 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
@@ -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;
@@ -652,7 +614,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 +634,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 +757,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 +956,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 +994,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
result = {
...result,
return {
...s,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -1039,10 +1003,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 +1344,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";
@@ -12,9 +12,8 @@ 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 +24,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 +108,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@@ -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) {
@@ -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";
@@ -14,9 +15,8 @@ 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 +57,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
@@ -69,7 +69,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 +132,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 (
+1 -1
View File
@@ -167,7 +167,7 @@ export class StateHistoryCharts extends LitElement {
)}`}
${this.syncCharts && this._hasZoomedCharts
? html`<ha-button
size="l"
size="large"
class="reset-button"
@click=${this._handleGlobalZoomReset}
>
+82 -93
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,13 +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";
@@ -127,7 +126,7 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _hiddenStats = new Set<string>();
@@ -252,101 +251,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() {
+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(
+2 -34
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) ||
@@ -358,15 +341,6 @@ 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}
`;
}
@@ -376,12 +350,6 @@ export class HaStatisticPicker extends LitElement {
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 -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}
+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>`,
});
}
+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}
+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);
}
+7 -22
View File
@@ -11,15 +11,12 @@ import {
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassConfig, Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@@ -39,24 +36,12 @@ export const CONDITION_ICONS = {
@customElement("ha-condition-icon")
export class HaConditionIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public condition?: string;
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
@transform<{ config: HassConfig }, HassConfig>({
transformer: ({ config }) => config,
})
private _config?: HassConfig;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<{ connection: Connection }, Connection>({
transformer: ({ connection }) => connection,
})
private _connection?: Connection;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -66,13 +51,13 @@ export class HaConditionIcon extends LitElement {
return nothing;
}
if (!this._connection || !this._config) {
if (!this.hass) {
return this._renderFallback();
}
const icon = conditionIcon(
this._connection,
this._config,
this.hass.connection,
this.hass.config,
this.condition
).then((icn) => {
if (icn) {
+1 -1
View File
@@ -61,7 +61,7 @@ export class HaDurationInput extends LitElement {
${this.allowNegative
? html`
<ha-button-toggle-group
size="s"
size="small"
.buttons=${[
{ label: "+", iconPath: mdiPlusThick, value: "+" },
{ label: "-", iconPath: mdiMinusThick, value: "-" },
-1
View File
@@ -107,7 +107,6 @@ export class HaExpansionPanel extends LitElement {
}
const newExpanded = !this.expanded;
fireEvent(this, "expanded-will-change", { expanded: newExpanded });
this._container.style.overflow = "hidden";
if (newExpanded) {
+1 -1
View File
@@ -120,7 +120,7 @@ export class HaFileUpload extends LitElement {
@dragend=${this._handleDragEnd}
>${!this.value
? html`<ha-button
size="s"
size="small"
appearance="filled"
@click=${this._openFilePicker}
>
+1 -7
View File
@@ -3,8 +3,6 @@ import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import type { Blueprints } from "../data/blueprint";
@@ -22,10 +20,6 @@ import "./ha-list";
export class HaFilterBlueprints extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value?: string[];
@property() public type?: "automation" | "script";
@@ -60,7 +54,7 @@ export class HaFilterBlueprints extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.panel.config.blueprint.caption")}
${this.hass.localize("ui.panel.config.blueprint.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
+134 -138
View File
@@ -1,5 +1,5 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -9,18 +9,14 @@ import { stringCompare } from "../common/string/compare";
import { deepEqual } from "../common/util/deep-equal";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import "./list/ha-list-selectable-virtualized";
import type { HaListSelectableVirtualized } from "./list/ha-list-selectable-virtualized";
import type { HaListVirtualizedItem } from "./list/ha-list-virtualized";
interface HaFilterDevicesItem extends HaListVirtualizedItem {
name: string;
}
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@@ -38,12 +34,15 @@ export class HaFilterDevices extends LitElement {
@state() private _filter?: string;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
@@ -52,20 +51,6 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded || !this._listElement) {
return;
}
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 38px - height of the search input
}, 300);
}
}
protected render() {
return html`
<ha-expansion-panel
@@ -81,7 +66,6 @@ export class HaFilterDevices extends LitElement {
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@keydown=${this._handleClearFilterKeydown}
></ha-icon-button>`
: nothing}
</div>
@@ -90,45 +74,75 @@ export class HaFilterDevices extends LitElement {
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(this.hass.devices, this._filter || "")}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._devices(
this.hass.devices,
this._filter || "",
this.value
)}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
</lit-virtualizer>
</ha-list>`
: nothing}
</ha-expansion-panel>
`;
}
private _renderItem = (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _keyFunction = (device) => device?.id;
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
...(this.value ?? []),
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
];
private _renderItem = (device) =>
!device
? nothing
: html`<ha-check-list-item
tabindex="0"
.value=${device.id}
.selected=${this.value?.includes(device.id) ?? false}
>
${computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)}
</ha-check-list-item>`;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleRemoved(ev: CustomEvent<number>) {
const id = this._devices(this.hass.devices, this._filter || "")[ev.detail]
.id;
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
@@ -141,38 +155,30 @@ export class HaFilterDevices extends LitElement {
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = target.value ?? "";
}
private _handleSearchKeydown(ev: KeyboardEvent) {
if (ev.key === "ArrowDown" && this._listElement) {
ev.preventDefault();
this._listElement.focus();
}
this._filter = (target.value ?? "").toLowerCase();
}
private _devices = memoizeOne(
(
devices: HomeAssistant["devices"],
filter: string
): HaFilterDevicesItem[] => {
(devices: HomeAssistant["devices"], filter: string, _value) => {
const values = Object.values(devices);
return values
.map((device) => ({
id: device.id,
interactive: true,
name: computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
),
}))
.filter(
({ name }) =>
!filter || name.toLowerCase().includes(filter.toLowerCase())
(device) =>
!filter ||
computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)
.toLowerCase()
.includes(filter)
)
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
stringCompare(
computeDeviceNameDisplay(a, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(b, this.hass.localize, this.hass.states),
this.hass.locale.language
)
);
}
);
@@ -211,13 +217,6 @@ export class HaFilterDevices extends LitElement {
});
}
private _handleClearFilterKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._clearFilter(ev);
}
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
@@ -225,61 +224,58 @@ export class HaFilterDevices extends LitElement {
value: undefined,
items: undefined,
});
this._listElement?.clearSelection();
}
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
:host([expanded]) ha-expansion-panel {
flex: 1;
min-height: 0;
}
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`;
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
width: 100%;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
+38 -48
View File
@@ -23,6 +23,7 @@ import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import type { HaListSelectedDetail } from "./list/types";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@@ -41,7 +42,7 @@ export class HaFilterFloorAreas extends LitElement {
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
@query("ha-list-selectable") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -74,7 +75,6 @@ export class HaFilterFloorAreas extends LitElement {
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@keydown=${this._handleClearFilterKeydown}
></ha-icon-button>`
: nothing}
</div>
@@ -83,8 +83,7 @@ export class HaFilterFloorAreas extends LitElement {
<ha-list-selectable
class="ha-scrollbar"
multi
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
@ha-list-selected=${this._handleListChanged}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
@@ -164,47 +163,46 @@ export class HaFilterFloorAreas extends LitElement {
`;
}
private _handleAdded(ev: CustomEvent<number>) {
if (!this.value) {
this.value = {};
}
const addedItem = (ev.currentTarget as HaListSelectable).items[
ev.detail
] as HaListItemOption & { type: string; value: string };
if (!addedItem) {
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
return;
}
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
}
if (ev.detail.diff?.added.size) {
const addedIndex = ev.detail.diff.added.values().next().value;
if (addedIndex === undefined) {
return;
}
const addedItem = (ev.currentTarget as HaListSelectable).items[
addedIndex
] as HaListItemOption & { type: string; value: string };
private _handleRemoved(ev: CustomEvent<number>) {
if (!this.value) {
return;
if (!this.value) {
this.value = {};
}
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
} else {
const removedIndex = ev.detail.diff?.removed.values().next().value;
if (removedIndex === undefined) {
return;
}
const removedItem = (ev.currentTarget as HaListSelectable).items[
removedIndex
] as HaListItemOption & { type: string; value: string };
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
(val) => val !== removedItem.value
),
};
}
const removedItem = (ev.currentTarget as HaListSelectable).items[
ev.detail
] as HaListItemOption & { type: string; value: string };
if (!removedItem) {
return;
}
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
(val) => val !== removedItem.value
),
};
}
protected updated(changed: PropertyValues<this>) {
@@ -288,13 +286,6 @@ export class HaFilterFloorAreas extends LitElement {
});
}
private _handleClearFilterKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._clearFilter(ev);
}
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
@@ -302,7 +293,6 @@ export class HaFilterFloorAreas extends LitElement {
value: undefined,
items: undefined,
});
this._list?.clearSelection();
}
static get styles(): CSSResultGroup {
+3 -7
View File
@@ -4,8 +4,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
@@ -24,10 +22,6 @@ import "../panels/config/voice-assistants/expose/expose-assistant-icon";
export class HaFilterVoiceAssistants extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
// the list of selected voiceAssistantIds
@property({ attribute: false }) public value: string[] = [];
@@ -50,7 +44,9 @@ export class HaFilterVoiceAssistants extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.panel.config.dashboard.voice_assistants.main")}
${this.hass.localize(
"ui.panel.config.dashboard.voice_assistants.main"
)}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -129,7 +129,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
@wa-select=${this._handleAddAction}
@closed=${stopPropagation}
>
<ha-button slot="trigger" appearance="filled" size="s">
<ha-button slot="trigger" appearance="filled" size="small">
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.localize?.("ui.components.form-optional-actions.add") ||
"Add interaction"}
+2 -2
View File
@@ -77,7 +77,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom";
| "left-end" = "bottom-start";
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@@ -174,7 +174,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
<slot name="field">
${this.addButtonLabel && !this.value
? html`<ha-button
size="s"
size="small"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
+4 -7
View File
@@ -3,12 +3,13 @@ import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import type { HomeAssistant } from "../types";
import "./ha-icon-button";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
@customElement("ha-icon-button-arrow-prev")
export class HaIconButtonArrowPrev extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@@ -24,15 +25,11 @@ export class HaIconButtonArrowPrev extends LitElement {
@state() private _icon =
mainWindow.document.dir === "rtl" ? mdiArrowRight : mdiArrowLeft;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
return html`
<ha-icon-button
.disabled=${this.disabled}
.label=${this.label || this._localize("ui.common.back") || "Back"}
.label=${this.label || this.hass?.localize("ui.common.back") || "Back"}
.path=${this._icon}
.href=${this.href}
.target=${this.target}
+1
View File
@@ -121,6 +121,7 @@ export class HaIconPicker extends LitElement {
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
<slot name="start"></slot>
+1 -30
View File
@@ -50,9 +50,7 @@ class HaLabel extends LitElement {
<div class="container" .id=${this._elementId}>
<span class="content">
<slot name="icon"></slot>
<span class="label-content">
<slot></slot>
</span>
<slot></slot>
</span>
</div>
`;
@@ -115,10 +113,6 @@ class HaLabel extends LitElement {
display: inline-flex;
}
.label-content {
display: contents;
}
:host([dense]) {
height: 20px;
border-radius: var(--ha-border-radius-md);
@@ -132,29 +126,6 @@ class HaLabel extends LitElement {
margin-inline-start: -4px;
margin-inline-end: 4px;
}
:host(.text-ellipsis) {
max-width: 100%;
min-width: 0;
}
:host(.text-ellipsis) .container {
min-width: 0;
overflow: hidden;
}
:host(.text-ellipsis) span.content {
display: flex;
width: 100%;
min-width: 0;
}
:host(.text-ellipsis) .label-content {
display: block;
flex: 1;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
];
}
+1 -1
View File
@@ -171,7 +171,7 @@ export class HaLabelsPicker extends LitElement {
: nothing}
<ha-button
id="picker"
size="s"
size="small"
appearance="filled"
@click=${this._openPicker}
.disabled=${this.disabled}
+1
View File
@@ -152,6 +152,7 @@ export class HaLanguagePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages"
@@ -53,7 +53,7 @@ class HaLawnMowerActionButton extends LitElement {
appearance="plain"
@click=${this.callService}
.service=${action.service}
size="s"
size="small"
>
${this.hass.localize(`ui.card.lawn_mower.actions.${action.action}`)}
</ha-button>
+1 -1
View File
@@ -102,7 +102,7 @@ export class HaPictureUpload extends LitElement {
<div>
<ha-button
appearance="plain"
size="s"
size="small"
variant="danger"
@click=${this._handleChangeClick}
>
+6 -14
View File
@@ -1,23 +1,19 @@
import { consume } from "@lit/context";
import { parseISO } from "date-fns";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { relativeTime } from "../common/datetime/relative_time";
import { capitalizeFirstLetter } from "../common/string/capitalize-first-letter";
import { internationalizationContext } from "../data/context";
import type { HomeAssistantInternationalization } from "../types";
import type { HomeAssistant } from "../types";
@customElement("ha-relative-time")
class HaRelativeTime extends ReactiveElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public datetime?: string | Date;
@property({ type: Boolean }) public capitalize = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _interval?: number;
public disconnectedCallback(): void {
@@ -61,19 +57,15 @@ class HaRelativeTime extends ReactiveElement {
}
private _updateRelative(): void {
if (!this._i18n) {
return;
}
if (!this.datetime) {
this.innerHTML = this._i18n.localize("ui.components.relative_time.never");
this.innerHTML = this.hass.localize("ui.components.relative_time.never");
} else {
const date =
typeof this.datetime === "string"
? parseISO(this.datetime)
: this.datetime;
const relTime = relativeTime(date, this._i18n.locale);
const relTime = relativeTime(date, this.hass.locale);
this.innerHTML = this.capitalize
? capitalizeFirstLetter(relTime)
: relTime;
+8 -15
View File
@@ -1,12 +1,11 @@
import { consume, type ContextType } from "@lit/context";
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 { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import { internationalizationContext, uiContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./radio/ha-radio-group";
import type { HaRadioGroup } from "./radio/ha-radio-group";
import "./radio/ha-radio-option";
@@ -27,6 +26,8 @@ export interface SelectBoxOption {
@customElement("ha-select-box")
export class HaSelectBox extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public options: SelectBoxOption[] = [];
@property({ attribute: false }) public value?: string;
@@ -39,14 +40,6 @@ export class HaSelectBox extends LitElement {
@property({ type: Boolean, attribute: "stacked_image" })
public stackedImage = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
protected _ui?: ContextType<typeof uiContext>;
render() {
const maxColumns = this.maxColumns ?? 3;
const columns = Math.min(maxColumns, this.options.length);
@@ -69,11 +62,11 @@ export class HaSelectBox extends LitElement {
const disabled = option.disabled || this.disabled || false;
const selected = option.value === this.value;
const isDark = this._ui?.themes.darkMode || false;
const isRTL = this._i18n
const isDark = this.hass?.themes.darkMode || false;
const isRTL = this.hass
? computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
this.hass.language,
this.hass.translationMetadata.translations
)
: false;
@@ -91,6 +91,7 @@ export class HaAreaSelector extends LitElement {
if (!this.selector.area?.multiple) {
return html`
<ha-area-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -1,13 +1,11 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { consumeEntityStates } from "../../common/decorators/consume-context-entry";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { AttributeSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-attribute-picker";
import { ensureArray } from "../../common/array/ensure-array";
@customElement("ha-selector-attribute")
export class HaSelectorAttribute extends LitElement {
@@ -29,10 +27,6 @@ export class HaSelectorAttribute extends LitElement {
filter_entity?: string | string[];
};
@state()
@consumeEntityStates({ entityIdPath: ["context", "filter_entity"] })
private _filterEntityStates?: Record<string, HassEntity>;
protected render() {
return html`
<ha-entity-attribute-picker
@@ -79,7 +73,7 @@ export class HaSelectorAttribute extends LitElement {
const entityIds = ensureArray(this.context.filter_entity);
invalid = !entityIds.some((entityId) => {
const stateObj = this._filterEntityStates?.[entityId];
const stateObj = this.hass.states[entityId];
return (
stateObj &&
this.value in stateObj.attributes &&
@@ -1,31 +1,30 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import type { LocalizeKeys } from "../../common/translations/localize";
import type {
AutomationBehavior,
AutomationBehaviorConditionMode,
AutomationBehaviorSelector,
AutomationBehaviorTriggerMode,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-input-helper-text";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
import "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"each",
"any",
"first",
"all",
"last",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public selector!: AutomationBehaviorSelector;
@@ -40,9 +39,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
@property({ type: Boolean }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
protected render() {
const { mode } = this.selector.automation_behavior ?? {};
const modeKey = mode ?? "trigger";
@@ -64,6 +60,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
return html`
<ha-select-box
.hass=${this.hass}
.options=${options}
.value=${this.value ?? ""}
max_columns="1"
@@ -98,10 +95,8 @@ export class HaSelectorAutomationBehavior extends LitElement {
return translated;
}
}
return (
this._localize?.(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
) || behavior
return this.hass.localize(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
);
}
@@ -1,12 +1,15 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
import "../ha-switch";
import "../ha-input-helper-text";
@customElement("ha-selector-boolean")
export class HaBooleanSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public value = false;
@property() public placeholder?: any;
@@ -1,26 +1,14 @@
import { consume } from "@lit/context";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { transform } from "../../common/decorators/transform";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { ButtonToggleSelector, SelectOption } from "../../data/selector";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HomeAssistantInternationalization,
ToggleButton,
} from "../../types";
import type { HomeAssistant, ToggleButton } from "../../types";
import "../ha-button-toggle-group";
@customElement("ha-selector-button_toggle")
export class HaButtonToggleSelector extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ButtonToggleSelector;
@@ -60,7 +48,11 @@ export class HaButtonToggleSelector extends LitElement {
if (this.selector.button_toggle?.sort) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
caseInsensitiveStringCompare(
a.label,
b.label,
this.hass.locale.language
)
);
}
@@ -2,12 +2,10 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { isTemplate } from "../../common/string/has-template";
import type { ChooseSelector, Selector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-button-toggle-group";
import "./ha-selector";
@@ -30,9 +28,6 @@ export class HaChooseSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
@state() public _activeChoice?: string;
protected willUpdate(changedProperties: PropertyValues<this>): void {
@@ -63,11 +58,11 @@ export class HaChooseSelector extends LitElement {
return html`<div class="multi-header">
<span>${this.label}${this.required ? "*" : ""}</span>
<ha-button-toggle-group
size="s"
size="small"
.buttons=${this._toggleButtons(
this.selector.choose.choices,
this.selector.choose.translation_key,
this._localize
this.hass.localize
)}
.active=${this._activeChoice}
@value-changed=${this._choiceChanged}
@@ -88,7 +83,7 @@ export class HaChooseSelector extends LitElement {
(
choices: ChooseSelector["choose"]["choices"],
translationKey?: string,
_localize?: LocalizeFunc
_localize?: HomeAssistant["localize"]
) =>
Object.keys(choices).map((choice) => ({
label:
+4 -13
View File
@@ -1,22 +1,13 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { transform } from "../../common/decorators/transform";
import { customElement, property, query } from "lit/decorators";
import type { DateSelector } from "../../data/selector";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import type { HomeAssistant } from "../../types";
import "../ha-date-input";
import type { HaDateInput } from "../ha-date-input";
@customElement("ha-selector-date")
export class HaDateSelector extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: DateSelector;
@@ -40,7 +31,7 @@ export class HaDateSelector extends LitElement {
return html`
<ha-date-input
.label=${this.label}
.locale=${this._locale}
.locale=${this.hass.locale}
.disabled=${this.disabled}
.value=${typeof this.value === "string" ? this.value : undefined}
.required=${this.required}
@@ -1,12 +1,8 @@
import { consume } from "@lit/context";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { transform } from "../../common/decorators/transform";
import type { DateTimeSelector } from "../../data/selector";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import type { HomeAssistant } from "../../types";
import "../ha-date-input";
import type { HaDateInput } from "../ha-date-input";
import "../ha-time-input";
@@ -15,12 +11,7 @@ import type { HaTimeInput } from "../ha-time-input";
@customElement("ha-selector-datetime")
export class HaDateTimeSelector extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: DateTimeSelector;
@@ -50,7 +41,7 @@ export class HaDateTimeSelector extends LitElement {
<div class="input">
<ha-date-input
.label=${this.label}
.locale=${this._locale}
.locale=${this.hass.locale}
.disabled=${this.disabled}
.required=${this.required}
.value=${values?.[0]}
@@ -60,7 +51,7 @@ export class HaDateTimeSelector extends LitElement {
<ha-time-input
enable-second
.value=${values?.[1] || "00:00:00"}
.locale=${this._locale}
.locale=${this.hass.locale}
.disabled=${this.disabled}
.required=${this.required}
@value-changed=${this._valueChanged}

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