Compare commits

..

61 Commits

Author SHA1 Message Date
Bram Kragten
d98e373f64 Bumped version to 20260128.6 2026-02-04 15:41:09 +01:00
Paul Bottein
649516c9fa Change default icon for blank area if not icon configured (#29394) 2026-02-04 15:40:36 +01:00
Paul Bottein
bbc4fb96b2 Load domain translation when integration page load (#29391) 2026-02-04 15:40:35 +01:00
Paul Bottein
0ae639aeb0 Remove old lovelace overview from pickers (#29390) 2026-02-04 15:40:34 +01:00
karwosts
0e7e41065e Don't shrink ha-dropdown checkboxes (#29387) 2026-02-04 15:40:33 +01:00
Paul Bottein
685843f112 Add translations for new overview dialog (#29382) 2026-02-04 15:40:32 +01:00
Paul Bottein
5e1a99d94a Use area icon for area empty state (#29371) 2026-02-04 15:40:31 +01:00
Bram Kragten
d843349865 Bumped version to 20260128.5 2026-02-03 16:58:01 +01:00
Paul Bottein
ec23164aa9 Improve other devices page in home dashboard (#29370) 2026-02-03 16:57:45 +01:00
Paul Bottein
e74ef11101 Hide edit and delete actions for YAML dashboards in config (#29368)
YAML dashboards are defined in configuration files and cannot be
modified or deleted through the UI. This change ensures the edit
and delete actions are only shown for storage-mode dashboards.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:57:43 +01:00
Paul Bottein
a222f6a736 Add missing danger variant in dropdown item (#29359) 2026-02-03 16:57:40 +01:00
Petar Petrov
ef3dd16d45 Move dialog scrim to pseudo-element (#29357) 2026-02-03 16:57:39 +01:00
karwosts
5d4e1d205e Fix missing imports in devtools-statistics (#29355) 2026-02-03 16:57:38 +01:00
Darryn Capes-Davis
1ee5ebbe75 Fix CSS minification issue for ha-card (#29354) 2026-02-03 16:57:37 +01:00
Bram Kragten
59d705aa3d Bumped version to 20260128.4 2026-02-02 17:17:24 +01:00
Paul Bottein
332e108dae Fix "Reload resources" menu for YAML resource mode (#29346) 2026-02-02 17:17:17 +01:00
karwosts
3c15b29d0a Entity diagnostic - handle entity not in the registry (#29344) 2026-02-02 17:17:16 +01:00
Wendelin
130c708e23 Fix dropdown width in datatables (#29340) 2026-02-02 17:17:15 +01:00
Paul Bottein
588a14a8a7 Fix type error for missing hass.themes race condition in themes mixin (#29338) 2026-02-02 17:17:14 +01:00
Petar Petrov
a1ef6ad266 Remove redundant dialog backdrop color (#29337) 2026-02-02 17:17:13 +01:00
Aidan Timson
a6c1f87730 Ensure template renderer overflows on overflow (#29335) 2026-02-02 17:17:12 +01:00
Wendelin
49252a3808 Fix missing ha-md-menu in config/labels (#29334) 2026-02-02 17:17:11 +01:00
Aidan Timson
c7877fe38f Show hint only if keyboard shortcuts is enabled (#29332)
Enabled by default, must be explicity disabled
2026-02-02 17:17:10 +01:00
Wendelin
e355a61d8f Revert "Fix automation sidebar ui supported check" (#29331) 2026-02-02 17:17:08 +01:00
Linus Rath
f2e19e51ce Update untracked consumption threshold to 1W (#29310) 2026-02-02 17:17:07 +01:00
karwosts
fd9ab8f561 Use ha-form for condition template (#29301) 2026-02-02 17:17:06 +01:00
Kristel
faa1b3c98f bugfix: add eventlistener for exposed-entities-changed to Entities page (#29299) 2026-02-02 17:17:06 +01:00
Aidan Timson
acc4a84fc9 Fix scrolling for labs page (#29287) 2026-02-02 17:17:05 +01:00
karwosts
4d723dac37 Fix areas cannot be deleted (#29285) 2026-02-02 17:17:03 +01:00
Aidan Timson
f1d4d0ef98 Fix type error for missing hass.config race condition in themes mixin (#29280) 2026-02-02 17:17:02 +01:00
Paul Bottein
88180a2708 Fix demo because of new default panel (#29279) 2026-02-02 17:17:01 +01:00
Aidan Timson
258d87e3d5 Add missing settings nav items for quick search (#29278)
* Add missing repairs quick search item

* Add voice assistants
2026-02-02 17:17:00 +01:00
Wendelin
55f22ba61a Implement fallback for dialog close event in Quick Search (#29260) 2026-02-02 17:16:59 +01:00
Aidan Timson
812f3ca8b9 Change default shortcut tip in Quick Search to mod+k, add tip to settings (#29253) 2026-02-02 17:16:58 +01:00
Marcin Bauer
7f880d11a0 Keep focus on search field when clicking filter chips (#29249)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:16:57 +01:00
Bram Kragten
6b2452c538 Update compress.js 2026-02-02 17:15:44 +01:00
Paul Bottein
c2cbf8bd21 Bumped version to 20260128.3 2026-01-30 10:31:26 +01:00
Wendelin
224bcece9c Fix multi select in quick search (#29272)
Add item selection state management to QuickBar component
2026-01-30 10:30:33 +01:00
Wendelin
dc84b7698f Fix --wa-color-text-normal (#29271)
Update normal text color variable in wa.globals.ts
2026-01-30 10:30:32 +01:00
Wendelin
bc22e6a9bd Fix device download diagnostic via overflow (#29269)
fix diagnostic download link handling to simplify URL signing
2026-01-30 10:30:31 +01:00
Simon Lamon
d44874783a Remove duplicated text (#29265) 2026-01-30 10:30:30 +01:00
Paul Bottein
8d1bb5c867 Fix default lovelace yaml loading (#29240) 2026-01-30 10:30:29 +01:00
Bram Kragten
da1b528eee Bumped version to 20260128.2 2026-01-29 18:04:42 +01:00
Paul Bottein
756138408a Remove default title for new dashboards (#29259) 2026-01-29 18:04:09 +01:00
Paul Bottein
3c8f112565 Prevent action in tile container (#29257) 2026-01-29 18:04:08 +01:00
Paul Bottein
2521f3dde4 Fix actions in dashboard overflow menu (#29256) 2026-01-29 18:04:07 +01:00
TheJulianJES
56390aa01a Fix Matter dashboard using disabled and ignored config entries (#29254) 2026-01-29 17:52:32 +01:00
Paul Bottein
9aac5b19da Stop click propagation when clicking item in icon overflow (#29252) 2026-01-29 17:52:31 +01:00
Wendelin
24afc3dc88 Prevent quick search to close from hot keys (#29251) 2026-01-29 17:52:30 +01:00
Paul Bottein
873c7b2947 Remove unused theme option in distribution card (#29250) 2026-01-29 17:52:29 +01:00
Aidan Timson
648db4276b Add protocols to quick search (#29248)
Add protocols to quick search, extract logic and translations
2026-01-29 17:52:28 +01:00
Aidan Timson
f86c3e7856 Remove unused "app" item from quick search (#29244) 2026-01-29 17:52:27 +01:00
Aidan Timson
1d0251cc28 Fixes for picker combo box scrolling and selection (#29242) 2026-01-29 17:52:26 +01:00
Wendelin
518cf87847 Fix quick search apps (#29238) 2026-01-29 17:52:25 +01:00
ildar170975
81a9216c44 computeGroupEntitiesState(): fix condition (#29234)
* fix condition

* fix condition

* prettier
2026-01-29 17:52:24 +01:00
Paul Bottein
f0e10e0058 Fix default yaml lovelace panel loading (#29230) 2026-01-29 17:52:23 +01:00
Paul Bottein
5df8ea4f07 Add welcome banner for new overview dashboard (#29223) 2026-01-29 17:52:22 +01:00
Aidan Timson
73f081f5cc Add meta+click/enter support to quick search (#29220)
* Allow meta+click event from combobox

* Handle new tab events for navigations

* Add mod+enter support for new tab

* Helper function
2026-01-29 17:52:21 +01:00
Petar Petrov
f0d1db1da6 Add non standard power sensor support (#28845)
* Add non standard power sensor support

* remove useless code

* GridPowerSourceInput type for grid power source saving
2026-01-29 17:52:20 +01:00
Bram Kragten
c658eb414b Bumped version to 20260128.1 2026-01-28 17:52:10 +01:00
Bram Kragten
bac493b72b dont include brotli compression 2026-01-28 17:50:19 +01:00
589 changed files with 15269 additions and 18129 deletions

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/devcontainers/python:3.14
FROM mcr.microsoft.com/devcontainers/python:1-3.13
ENV \
DEBIAN_FRONTEND=noninteractive \

View File

@@ -21,14 +21,6 @@
-->
## Screenshots
<!--
If your PR includes visual changes, please add screenshots or a short video
showing the before and after. This helps reviewers understand the impact of
your changes.
Note: Remove this section if this PR has no visual changes.
-->
## Type of change
<!--
What type of change does your PR introduce to the Home Assistant frontend?
@@ -43,6 +35,16 @@
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [ ] Code quality improvements to existing code or addition of tests
## Example configuration
<!--
Supplying a configuration snippet, makes it easier for a maintainer to test
your PR.
-->
```yaml
```
## Additional information
<!--
Details are important, and help maintainers processing your PR.
@@ -52,8 +54,6 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to backend pull request:
## Checklist
<!--
@@ -61,50 +61,18 @@
creating the PR. If you're unsure about any of them, don't hesitate to ask.
We're here to help! This is simply a reminder of what we are going to look
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
-->
- [ ] I understand the code I am submitting and can explain how it works.
- [ ] The code change is tested and works locally.
- [ ] There is no commented out code in this PR.
- [ ] I have followed the [development checklist][dev-checklist]
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
- [ ] Tests have been added to verify that the new code works.
If user exposed functionality or configuration variables are added/changed:
- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository]
<!--
This project is very active and we have a high turnover of pull requests.
Unfortunately, the number of incoming pull requests is higher than what our
reviewers can review and merge so there is a long backlog of pull requests
waiting for review. You can help here!
By reviewing another pull request, you will help raise the code quality of
that pull request and the final review will be faster. This way the general
pace of pull request reviews will go up and your wait time will go down.
When picking a pull request to review, try to choose one that hasn't yet
been reviewed.
Thanks for helping out!
-->
To help with the load of incoming pull requests:
- [ ] I have reviewed two other [open pull requests][prs] in this repository.
[prs]: https://github.com/home-assistant/frontend/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+sort%3Acreated-desc+review%3Anone+-status%3Afailure
<!--
Thank you for contributing <3
Below, some useful links you could explore:
-->
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr

View File

@@ -194,13 +194,13 @@ The View Transitions API creates smooth animations between DOM state changes. Wh
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-duration-fast` (150ms), `--ha-animation-duration-normal` (250ms), `--ha-animation-duration-slow` (350ms) (all respect `prefers-reduced-motion`)
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-base-duration` (350ms, respects `prefers-reduced-motion`)
**Implementation Guidelines:**
1. Always use `withViewTransition()` wrapper for automatic fallback
2. Keep transitions simple (subtle crossfades and fades work best)
3. Use `--ha-animation-duration-*` CSS variables for consistent timing (`fast`, `normal`, `slow`)
3. Use `--ha-animation-base-duration` CSS variable for consistent timing
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
@@ -214,6 +214,13 @@ By default, `:root` receives `view-transition-name: root`, creating a full-page
- Only one view transition can run at a time
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
**Current Usage & Planned Applications:**
- Launch screen fade out (implemented)
- Automation sidebar transitions (planned - #27238)
- More info dialog content changes (planned - #27672)
- Toolbar navigation, ha-spinner transitions (planned)
**Specification & Documentation:**
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
@@ -239,7 +246,13 @@ For browser support, API details, and current specifications, refer to these aut
## Component Library
### Dialog Component
### Dialog Components
**Available Dialog Types:**
- `ha-wa-dialog` - Preferred for new dialogs (Web Awesome based)
- `ha-md-dialog` - Material Design 3 dialog component
- `ha-dialog` - Legacy component (still widely used)
**Opening Dialogs (Fire Event Pattern - Recommended):**
@@ -253,7 +266,6 @@ fireEvent(this, "show-dialog", {
**Dialog Implementation Requirements:**
- Use `ha-dialog` component
- Implement `HassDialog<T>` interface
- Use `@state() private _open = false` to control dialog visibility
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
@@ -269,6 +281,7 @@ fireEvent(this, "show-dialog", {
- 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
- Example: `<ha-wa-dialog width="small">` for alert/confirmation dialogs
**Button Appearance Guidelines:**
@@ -278,9 +291,17 @@ fireEvent(this, "show-dialog", {
- **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`
**Recent Examples:**
See these files for current patterns:
- `src/panels/config/repairs/dialog-repairs-issue.ts`
- `src/dialogs/restart/dialog-restart.ts`
- `src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts`
**Gallery Documentation:**
- `gallery/src/pages/components/ha-dialog.markdown`
- `gallery/src/pages/components/ha-wa-dialog.markdown`
- `gallery/src/pages/components/ha-dialogs.markdown`
### Form Component (ha-form)
@@ -288,6 +309,7 @@ fireEvent(this, "show-dialog", {
- Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display
- Use `dialogInitialFocus` in dialogs
- Use `computeLabel`, `computeError`, `computeHelper` for translations
```typescript
@@ -372,6 +394,81 @@ export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
}
```
### Creating a Dialog
```typescript
@customElement("dialog-my-feature")
export class DialogMyFeature
extends LitElement
implements HassDialog<MyDialogParams>
{
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _params?: MyDialogParams;
@state()
private _open = false;
public async showDialog(params: MyDialogParams): Promise<void> {
this._params = params;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._params.title}
header-subtitle=${this._params.subtitle}
@closed=${this._dialogClosed}
>
<p>Dialog content</p>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
static styles = [haStyleDialog, css``];
}
```
### Dialog Design Guidelines
- Max width: 560px (Alert/confirmation: 320px fixed width)
- Close X-icon on top left (all screen sizes)
- Submit button grouped with cancel at bottom right
- Keep button labels short: "Save", "Delete", "Enable"
- Destructive actions use red warning button
- Always use a title (best practice)
- Strive for minimalism
#### Creating a Lovelace Card
**Purpose**: Cards allow users to tell different stories about their house (based on gallery)
@@ -462,10 +559,6 @@ this.hass.localize("ui.panel.config.updates.update_available", {
- Use HTTPS - All external resources must use HTTPS
- CSP compliance - Ensure code works with Content Security Policy
### Pull Requests
When creating a pull request, you **must** use the PR template located at `.github/PULL_REQUEST_TEMPLATE.md`. Read the template file and use its full content as the PR body, filling in each section appropriately. Do not omit, reorder, or rewrite the template sections. Do not check the checklist items on behalf of the user — those are the user's responsibility to review and check. If the PR includes UI changes, remind the user to add screenshots or a short video to the PR after creating it.
### Text and Copy Guidelines
#### Terminology Standards
@@ -623,6 +716,9 @@ this.hass.localize("ui.panel.config.automation.delete_confirm", {
### Component-Specific Checks
- [ ] Dialogs implement HassDialog interface
- [ ] Dialog styling uses haStyleDialog
- [ ] Dialog accessibility includes dialogInitialFocus
- [ ] ha-alert used correctly for messages
- [ ] ha-form uses proper schema structure
- [ ] Components handle all states (loading, error, unavailable)

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 1 * * *"
env:
PYTHON_VERSION: "3.14"
PYTHON_VERSION: "3.13"
NODE_OPTIONS: --max_old_space_size=6144
permissions:

View File

@@ -6,7 +6,7 @@ on:
- published
env:
PYTHON_VERSION: "3.14"
PYTHON_VERSION: "3.13"
NODE_OPTIONS: --max_old_space_size=6144
# Set default workflow permissions
@@ -84,7 +84,7 @@ jobs:
- name: Build wheels
uses: home-assistant/wheels@2025.12.0
with:
abi: cp314
abi: cp313
tag: musllinux_1_2
arch: amd64
wheels-key: ${{ secrets.WHEELS_KEY }}

2
.nvmrc
View File

@@ -1 +1 @@
24.13.1
24.13.0

View File

@@ -14,28 +14,40 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
energy_sources: [
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_1",
stat_energy_to: "sensor.energy_production_tarif_1",
stat_cost: "sensor.energy_consumption_tarif_1_cost",
stat_compensation: "sensor.energy_production_tarif_1_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid",
cost_adjustment_day: 0,
},
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_2",
stat_energy_to: "sensor.energy_production_tarif_2",
stat_cost: "sensor.energy_consumption_tarif_2_cost",
stat_compensation: "sensor.energy_production_tarif_2_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid_return",
flow_from: [
{
stat_energy_from: "sensor.energy_consumption_tarif_1",
stat_cost: "sensor.energy_consumption_tarif_1_cost",
entity_energy_price: null,
number_energy_price: null,
},
{
stat_energy_from: "sensor.energy_consumption_tarif_2",
stat_cost: "sensor.energy_consumption_tarif_2_cost",
entity_energy_price: null,
number_energy_price: null,
},
],
flow_to: [
{
stat_energy_to: "sensor.energy_production_tarif_1",
stat_compensation:
"sensor.energy_production_tarif_1_compensation",
entity_energy_price: null,
number_energy_price: null,
},
{
stat_energy_to: "sensor.energy_production_tarif_2",
stat_compensation:
"sensor.energy_production_tarif_2_compensation",
entity_energy_price: null,
number_energy_price: null,
},
],
power: [
{ stat_rate: "sensor.power_grid" },
{ stat_rate: "sensor.power_grid_return" },
],
cost_adjustment_day: 0,
},
{

View File

@@ -1,12 +1,14 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let sidebarChangeCallback;
let changeFunction;
export const mockFrontend = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_user_data", () => ({ value: null }));
hass.mockWS("frontend/get_user_data", () => ({
value: null,
}));
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
if (key === "sidebar") {
sidebarChangeCallback?.({
changeFunction?.({
value: {
panelOrder: value.panelOrder || [],
hiddenPanels: value.hiddenPanels || [],
@@ -14,11 +16,14 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
});
}
});
hass.mockWS("frontend/subscribe_user_data", (msg, _hass, onChange) => {
if (msg.key === "sidebar") {
sidebarChangeCallback = onChange;
}
onChange?.({ value: null });
hass.mockWS("frontend/subscribe_user_data", (_msg, _hass, onChange) => {
changeFunction = onChange;
onChange?.({
value: {
panelOrder: [],
hiddenPanels: [],
},
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
});
@@ -43,5 +48,4 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
return () => {};
});
hass.mockWS("repairs/list_issues", () => ({ issues: [] }));
hass.mockWS("frontend/get_themes", (_msg, currentHass) => currentHass.themes);
};

View File

@@ -29,7 +29,6 @@ export const mockLovelace = (
hass.mockWS("lovelace/config/save", () => Promise.resolve());
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
};
customElements.whenDefined("hui-root").then(() => {

View File

@@ -12,7 +12,6 @@ import eslintConfigPrettier from "eslint-config-prettier";
import { configs as litConfigs } from "eslint-plugin-lit";
import { configs as wcConfigs } from "eslint-plugin-wc";
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
@@ -193,13 +192,5 @@ export default tseslint.config(
languageOptions: {
globals: globals.audioWorklet,
},
},
{
plugins: {
html,
},
rules: {
"html/no-invalid-attr-value": "error",
},
}
);

View File

@@ -10,9 +10,7 @@ As a community, we are proud of our logo. Follow these guidelines to ensure it a
![Logo](/images/brand/logo.png)
<ha-alert alert-type="info">
This logo is trademarked and the property of the Open Home Foundation. This means it is not available for commercial use without express written permission from the foundation. We regard commercial use as anything designed to market or promote a product, software or service that is for sale. Please contact <a href="mailto:partner@openhomefoundation.org">partner@openhomefoundation.org</a> for further information
</ha-alert>
Please note that this logo is not released under the CC license. All rights reserved.
# Design

View File

@@ -1 +0,0 @@
import "../../../../src/components/ha-alert";

View File

@@ -215,7 +215,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<li>
<strong>Dialog mode:</strong> Used on larger screens (width &gt;
870px and height &gt; 500px). Renders as a centered dialog using
<code>ha-dialog</code>.
<code>ha-wa-dialog</code>.
</li>
<li>
<strong>Bottom sheet mode:</strong> Used on mobile devices and
@@ -394,7 +394,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<p>
If you don't need responsive behavior, use
<code>ha-dialog</code> directly for desktop-only dialogs or
<code>ha-wa-dialog</code> directly for desktop-only dialogs or
<code>ha-bottom-sheet</code> for mobile-only sheets.
</p>
@@ -447,7 +447,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<h3>API</h3>
<p>
This component combines <code>ha-dialog</code> and
This component combines <code>ha-wa-dialog</code> and
<code>ha-bottom-sheet</code> with automatic mode switching based on
screen size.
</p>

View File

@@ -1,3 +0,0 @@
---
title: Dialog (ha-dialog)
---

View File

@@ -0,0 +1,3 @@
---
title: Dialog (ha-wa-dialog)
---

View File

@@ -6,7 +6,7 @@ import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dialog-footer";
import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-dialog";
import "../../../../src/components/ha-wa-dialog";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
const SCHEMA: HaFormSchema[] = [
@@ -22,14 +22,14 @@ type DialogType =
| "form"
| "actions";
@customElement("demo-components-ha-dialog")
export class DemoHaDialog extends LitElement {
@customElement("demo-components-ha-wa-dialog")
export class DemoHaWaDialog extends LitElement {
@state() private _openDialog: DialogType = false;
protected render() {
return html`
<div class="content">
<h1>Dialog <code>&lt;ha-dialog&gt;</code></h1>
<h1>Dialog <code>&lt;ha-wa-dialog&gt;</code></h1>
<p class="subtitle">Dialog component built with WebAwesome.</p>
@@ -53,24 +53,24 @@ export class DemoHaDialog extends LitElement {
>
</div>
<ha-dialog
<ha-wa-dialog
.open=${this._openDialog === "basic"}
header-title="Basic dialog"
@closed=${this._handleClosed}
>
<div>Dialog content</div>
</ha-dialog>
</ha-wa-dialog>
<ha-dialog
<ha-wa-dialog
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Basic dialog with subtitle"
header-subtitle="This is a basic dialog with a subtitle below"
@closed=${this._handleClosed}
>
<div>Dialog content</div>
</ha-dialog>
</ha-wa-dialog>
<ha-dialog
<ha-wa-dialog
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Dialog with subtitle above"
header-subtitle="This is a basic dialog with a subtitle above"
@@ -78,9 +78,9 @@ export class DemoHaDialog extends LitElement {
@closed=${this._handleClosed}
>
<div>Dialog content</div>
</ha-dialog>
</ha-wa-dialog>
<ha-dialog
<ha-wa-dialog
.open=${this._openDialog === "form"}
header-title="Dialog with form"
header-subtitle="This is a dialog with a form and a footer"
@@ -91,18 +91,17 @@ export class DemoHaDialog extends LitElement {
<ha-dialog-footer slot="footer">
<ha-button
data-dialog="close"
appearance="plain"
slot="secondaryAction"
variant="plain"
>Cancel</ha-button
>
<ha-button data-dialog="close" slot="primaryAction" variant="accent"
>Submit</ha-button
>
Cancel
</ha-button>
<ha-button data-dialog="close" slot="primaryAction">
Submit
</ha-button>
</ha-dialog-footer>
</ha-dialog>
</ha-wa-dialog>
<ha-dialog
<ha-wa-dialog
.open=${this._openDialog === "actions"}
header-title="Dialog with actions"
header-subtitle="This is a dialog with header actions"
@@ -114,7 +113,7 @@ export class DemoHaDialog extends LitElement {
</div>
<div>Dialog content</div>
</ha-dialog>
</ha-wa-dialog>
<h2>Design</h2>
@@ -229,19 +228,19 @@ export class DemoHaDialog extends LitElement {
<tr>
<th>Slot</th>
<th>Description</th>
<th>Appearance to use</th>
<th>Variant to use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>secondaryAction</code></td>
<td>The secondary action button(s).</td>
<td><code>appearance="plain"</code></td>
<td><code>plain</code></td>
</tr>
<tr>
<td><code>primaryAction</code></td>
<td>The primary action button(s).</td>
<td>Default (no appearance attribute)</td>
<td><code>accent</code></td>
</tr>
</tbody>
</table>
@@ -250,7 +249,7 @@ export class DemoHaDialog extends LitElement {
<h3>Example Usage</h3>
<pre><code>&lt;ha-dialog
<pre><code>&lt;ha-wa-dialog
open
header-title="Dialog title"
header-subtitle="Dialog subtitle"
@@ -262,18 +261,12 @@ export class DemoHaDialog extends LitElement {
&lt;/div&gt;
&lt;div&gt;Dialog content&lt;/div&gt;
&lt;ha-dialog-footer slot="footer"&gt;
&lt;ha-button
data-dialog="close"
appearance="plain"
slot="secondaryAction"
&lt;ha-button data-dialog="close" slot="secondaryAction" variant="plain"
&gt;Cancel&lt;/ha-button
&gt;
Cancel
&lt;/ha-button&gt;
&lt;ha-button data-dialog="close" slot="primaryAction"&gt;
Submit
&lt;/ha-button&gt;
&lt;ha-button slot="primaryAction" variant="accent"&gt;Submit&lt;/ha-button&gt;
&lt;/ha-dialog-footer&gt;
&lt;/ha-dialog&gt;</code></pre>
&lt;/ha-wa-dialog&gt;</code></pre>
<h3>API</h3>
@@ -521,6 +514,6 @@ export class DemoHaDialog extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-dialog": DemoHaDialog;
"demo-components-ha-wa-dialog": DemoHaWaDialog;
}
}

View File

@@ -18,7 +18,7 @@ The Home Assistant interface is based on Material Design. It's a design system c
We want to make it as easy for designers to contribute as it is for developers. Theres a lot a designer can contribute to:
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Meet us at <a href="https://www.home-assistant.io/join-chat" rel="noopener noreferrer" target="_blank">devs_ux Discord</a>. Feel free to share your designs, user test or strategic ideas.
- Start designing with our <a href="https://www.figma.com/community/file/967153512097289521/Home-Assistant-DesignKit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!

View File

@@ -100,6 +100,7 @@ class HaLandingPage extends LandingPageBaseElement {
button-style
native-name
@value-changed=${this._languageChanged}
inline-arrow
></ha-language-picker>
<ha-button
appearance="plain"

View File

@@ -27,32 +27,32 @@
"type": "module",
"dependencies": {
"@babel/runtime": "7.28.6",
"@braintree/sanitize-url": "7.1.2",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.20.0",
"@codemirror/commands": "6.10.2",
"@codemirror/commands": "6.10.1",
"@codemirror/language": "6.12.1",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.12",
"@codemirror/view": "6.39.11",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.2.1",
"@formatjs/intl-displaynames": "7.2.1",
"@formatjs/intl-durationformat": "0.10.1",
"@formatjs/intl-getcanonicallocales": "3.2.1",
"@formatjs/intl-listformat": "8.2.1",
"@formatjs/intl-locale": "5.2.1",
"@formatjs/intl-numberformat": "9.2.2",
"@formatjs/intl-pluralrules": "6.2.2",
"@formatjs/intl-relativetimeformat": "12.2.2",
"@formatjs/intl-datetimeformat": "7.2.0",
"@formatjs/intl-displaynames": "7.2.0",
"@formatjs/intl-durationformat": "0.10.0",
"@formatjs/intl-getcanonicallocales": "3.2.0",
"@formatjs/intl-listformat": "8.2.0",
"@formatjs/intl-locale": "5.2.0",
"@formatjs/intl-numberformat": "9.2.1",
"@formatjs/intl-pluralrules": "6.2.1",
"@formatjs/intl-relativetimeformat": "12.2.1",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.2.1-ha.0",
"@home-assistant/webawesome": "3.0.0-ha.2",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -71,6 +71,7 @@
"@material/mwc-icon-button": "0.27.0",
"@material/mwc-linear-progress": "0.27.0",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/mwc-menu": "0.27.0",
"@material/mwc-radio": "0.27.0",
"@material/mwc-select": "0.27.0",
"@material/mwc-snackbar": "0.27.0",
@@ -88,7 +89,7 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -111,7 +112,7 @@
"hls.js": "1.6.15",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.1.2",
"intl-messageformat": "11.1.1",
"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",
@@ -121,7 +122,7 @@
"luxon": "3.7.2",
"marked": "17.0.1",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
"punycode": "2.3.1",
"qr-scanner": "1.4.2",
@@ -132,7 +133,7 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.9",
"ua-parser-js": "2.0.8",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@@ -145,18 +146,17 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/core": "7.28.6",
"@babel/helper-define-polyfill-provider": "0.6.6",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.0",
"@bundle-stats/plugin-webpack-filter": "4.21.9",
"@html-eslint/eslint-plugin": "0.55.0",
"@lokalise/node-api": "15.6.1",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.6",
"@bundle-stats/plugin-webpack-filter": "4.21.8",
"@lokalise/node-api": "15.6.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.2",
"@rspack/core": "1.7.5",
"@rsdoctor/rspack-plugin": "1.5.0",
"@rspack/core": "1.7.3",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -192,14 +192,14 @@
"eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0",
"fs-extra": "11.3.3",
"glob": "13.0.1",
"glob": "13.0.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "28.0.0",
"jsdom": "27.4.0",
"jszip": "3.10.1",
"lint-staged": "16.2.7",
"lit-analyzer": "2.0.3",
@@ -211,11 +211,11 @@
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.5",
"sinon": "21.0.1",
"tar": "7.5.7",
"tar": "7.5.6",
"terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.54.0",
"typescript-eslint": "8.53.1",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18",
"webpack-stats-plugin": "1.1.3",
@@ -229,13 +229,13 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
"globals": "17.3.0",
"globals": "17.1.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.12.0",
"volta": {
"node": "24.13.1"
"node": "24.13.0"
}
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260128.0"
version = "20260128.6"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -12,7 +12,7 @@ readme = "README.md"
authors = [
{name = "The Home Assistant Authors", email = "hello@home-assistant.io"}
]
requires-python = ">=3.14.0"
requires-python = ">=3.13.0"
[project.urls]
"Homepage" = "https://github.com/home-assistant/frontend"

View File

@@ -16,12 +16,6 @@ if [[ -n "$DEVCONTAINER" ]]; then
nvm install --reinstall-packages-from="$nodeCurrent" --default
nvm uninstall "$nodeCurrent"
fi
# install yarn if not already available
if ! command -v yarn &> /dev/null; then
npm install -g corepack
yes | yarn
fi
fi
if ! command -v yarn &> /dev/null; then

View File

@@ -194,6 +194,7 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
button-style
native-name
@value-changed=${this._languageChanged}
inline-arrow
></ha-language-picker>
<ha-button
appearance="plain"

View File

@@ -116,6 +116,3 @@ export const UNIT_F = "°F";
/** Entity ID of the default view. */
export const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
/** String to visually separate labels on UI */
export const STRINGS_SEPARATOR_DOT = " · ";

View File

@@ -3,14 +3,13 @@ import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation";
import type { HomeAssistant, ValuePart } from "../../types";
import type { HomeAssistant } from "../../types";
import { formatDate } from "../datetime/format_date";
import { formatDateTime } from "../datetime/format_date_time";
import { DURATION_UNITS, formatDuration } from "../datetime/format_duration";
import { formatTime } from "../datetime/format_time";
import {
formatNumber,
formatNumberToParts,
getNumberFormatOptions,
isNumericFromAttributes,
} from "../number/format_number";
@@ -52,36 +51,8 @@ export const computeStateDisplayFromEntityAttributes = (
attributes: any,
state: string
): string => {
const parts = computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
entityId,
attributes,
state
);
return parts.map((part) => part.value).join("");
};
const computeStateToPartsFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
attributes: any,
state: string
): ValuePart[] => {
if (state === UNKNOWN || state === UNAVAILABLE) {
return [
{
type: "value",
value: localize(`state.default.${state}`),
},
];
return localize(`state.default.${state}`);
}
const domain = computeDomain(entityId);
@@ -102,27 +73,19 @@ const computeStateToPartsFromEntityAttributes = (
DURATION_UNITS.includes(attributes.unit_of_measurement)
) {
try {
return [
{
type: "value",
value: formatDuration(
locale,
state,
attributes.unit_of_measurement,
entity?.display_precision
),
},
];
return formatDuration(
locale,
state,
attributes.unit_of_measurement,
entity?.display_precision
);
} catch (_err) {
// fallback to default
}
}
// state is monetary
if (attributes.device_class === "monetary") {
let parts: Record<string, string>[] = [];
try {
parts = formatNumberToParts(state, locale, {
return formatNumber(state, locale, {
style: "currency",
currency: attributes.unit_of_measurement,
minimumFractionDigits: 2,
@@ -135,34 +98,8 @@ const computeStateToPartsFromEntityAttributes = (
} catch (_err) {
// fallback to default
}
const TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
literal: "literal",
currency: "unit",
};
const valueParts: ValuePart[] = [];
for (const part of parts) {
const type = TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
valueParts.push({ type, value: part.value });
}
}
return valueParts;
}
// default processing of numeric values
const value = formatNumber(
state,
locale,
@@ -177,14 +114,10 @@ const computeStateToPartsFromEntityAttributes = (
attributes.unit_of_measurement;
if (unit) {
return [
{ type: "value", value: value },
{ type: "literal", value: blankBeforeUnit(unit, locale) },
{ type: "unit", value: unit },
];
return `${value}${blankBeforeUnit(unit, locale)}${unit}`;
}
return [{ type: "value", value: value }];
return value;
}
if (["date", "input_datetime", "time"].includes(domain)) {
@@ -196,51 +129,36 @@ const computeStateToPartsFromEntityAttributes = (
const components = state.split(" ");
if (components.length === 2) {
// Date and time.
return [
{
type: "value",
value: formatDateTime(
new Date(components.join("T")),
{ ...locale, time_zone: TimeZone.local },
config
),
},
];
return formatDateTime(
new Date(components.join("T")),
{ ...locale, time_zone: TimeZone.local },
config
);
}
if (components.length === 1) {
if (state.includes("-")) {
// Date only.
return [
{
type: "value",
value: formatDate(
new Date(`${state}T00:00`),
{ ...locale, time_zone: TimeZone.local },
config
),
},
];
return formatDate(
new Date(`${state}T00:00`),
{ ...locale, time_zone: TimeZone.local },
config
);
}
if (state.includes(":")) {
// Time only.
const now = new Date();
return [
{
type: "value",
value: formatTime(
new Date(`${now.toISOString().split("T")[0]}T${state}`),
{ ...locale, time_zone: TimeZone.local },
config
),
},
];
return formatTime(
new Date(`${now.toISOString().split("T")[0]}T${state}`),
{ ...locale, time_zone: TimeZone.local },
config
);
}
}
return [{ type: "value", value: state }];
return state;
} catch (_e) {
// Formatting methods may throw error if date parsing doesn't go well,
// just return the state string in that case.
return [{ type: "value", value: state }];
return state;
}
}
@@ -264,58 +182,25 @@ const computeStateToPartsFromEntityAttributes = (
(domain === "sensor" && attributes.device_class === "timestamp")
) {
try {
return [
{
type: "value",
value: formatDateTime(new Date(state), locale, config),
},
];
return formatDateTime(new Date(state), locale, config);
} catch (_err) {
return [{ type: "value", value: state }];
return state;
}
}
return [
{
type: "value",
value:
(entity?.translation_key &&
localize(
`component.${entity.platform}.entity.${domain}.${entity.translation_key}.state.${state}`
)) ||
// Return device class translation
(attributes.device_class &&
localize(
`component.${domain}.entity_component.${attributes.device_class}.state.${state}`
)) ||
// Return default translation
localize(`component.${domain}.entity_component._.state.${state}`) ||
// We don't know! Return the raw state.
state,
},
];
};
export const computeStateToParts = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entities: HomeAssistant["entities"],
state?: string
): ValuePart[] => {
const entity = entities?.[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
return computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
stateObj.attributes,
state !== undefined ? state : stateObj.state
return (
(entity?.translation_key &&
localize(
`component.${entity.platform}.entity.${domain}.${entity.translation_key}.state.${state}`
)) ||
// Return device class translation
(attributes.device_class &&
localize(
`component.${domain}.entity_component.${attributes.device_class}.state.${state}`
)) ||
// Return default translation
localize(`component.${domain}.entity_component._.state.${state}`) ||
// We don't know! Return the raw state.
state
);
};

View File

@@ -38,18 +38,6 @@ export const getEntityContext = (
return getEntityEntryContext(entry, entities, devices, areas, floors);
};
export const getEntityAreaId = (
entityId: string,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = entities[entityId];
if (!entry) return undefined;
const deviceId = entry.device_id;
const device = deviceId ? devices[deviceId] : undefined;
return entry.area_id || device?.area_id || undefined;
};
export const getEntityEntryContext = (
entry:
| EntityRegistryDisplayEntry

View File

@@ -5,6 +5,7 @@ import type {
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { FrontendLocaleData } from "../../data/translation";
import { NumberFormat } from "../../data/translation";
import { round } from "./round";
/**
* Returns true if the entity is considered numeric based on the attributes it has
@@ -51,22 +52,7 @@ export const formatNumber = (
num: string | number,
localeOptions?: FrontendLocaleData,
options?: Intl.NumberFormatOptions
): string =>
formatNumberToParts(num, localeOptions, options)
.map((part) => part.value)
.join("");
/**
* Returns an array of objects containing the formatted number in parts
* Similar to Intl.NumberFormat.prototype.formatToParts()
*
* Input params - same as for formatNumber()
*/
export const formatNumberToParts = (
num: string | number,
localeOptions?: FrontendLocaleData,
options?: Intl.NumberFormatOptions
): any[] => {
): string => {
const locale = localeOptions
? numberFormatToLocale(localeOptions)
: undefined;
@@ -85,7 +71,7 @@ export const formatNumberToParts = (
return new Intl.NumberFormat(
locale,
getDefaultFormatOptions(num, options)
).formatToParts(Number(num));
).format(Number(num));
}
if (
@@ -100,10 +86,15 @@ export const formatNumberToParts = (
...options,
useGrouping: false,
})
).formatToParts(Number(num));
).format(Number(num));
}
return [{ type: "literal", value: num }];
if (typeof num === "string") {
return num;
}
return `${round(num, options?.maximumFractionDigits).toString()}${
options?.style === "currency" ? ` ${options.currency}` : ""
}`;
};
/**

View File

@@ -1,28 +0,0 @@
const SI_PREFIX_MULTIPLIERS: Record<string, number> = {
T: 1e12,
G: 1e9,
M: 1e6,
k: 1e3,
m: 1e-3,
"\u00B5": 1e-6, // µ (micro sign)
"\u03BC": 1e-6, // μ (greek small letter mu)
};
/**
* Normalize a numeric value by detecting SI unit prefixes (T, G, M, k, m, µ).
* Only applies when the unit is longer than 1 character and starts with a
* recognized prefix, avoiding false positives on standalone units like "m" (meters).
*/
export const normalizeValueBySIPrefix = (
value: number,
unit: string | undefined
): number => {
if (!unit || unit.length <= 1) {
return value;
}
const prefix = unit[0];
if (prefix in SI_PREFIX_MULTIPLIERS) {
return value * SI_PREFIX_MULTIPLIERS[prefix];
}
return value;
};

View File

@@ -1,507 +0,0 @@
import type { TemplateResult } from "lit";
import { css, html, unsafeCSS } from "lit";
import { normalizeSearchText, splitSearchTerms } from "./search-query";
export interface HighlightRange {
start: number;
end: number;
}
interface NormalizedIndexMap {
normalizedText: string;
normalizedIndexMap: HighlightRange[];
}
export type HighlightedText =
| string
| TemplateResult
| (string | TemplateResult)[]
| null
| undefined;
const HIGHLIGHT_NAME_PREFIX = "ha-search";
// Shared selector so range extraction and mutation checks stay in sync.
const HIGHLIGHT_MARK_SELECTOR = "mark.ha-highlight";
const tokenizeSearchQuery = (query: string): string[] => [
...new Set(splitSearchTerms(query)),
];
/**
* Build normalized text and an index map back to original indexes.
* Needed because normalization can change character length/index positions.
*/
const buildNormalizedIndexMap = (
text: string,
language?: string
): NormalizedIndexMap => {
let normalizedText = "";
const normalizedIndexMap: HighlightRange[] = [];
let originalIndex = 0;
for (const char of text) {
const start = originalIndex;
const end = start + char.length;
const normalizedChar = normalizeSearchText(char, language);
normalizedText += normalizedChar;
// One original character can normalize into multiple UTF-16 code units.
// Keep a mapping entry for each normalized code unit because String#indexOf
// and String#length operate on UTF-16 indexes.
for (const _codeUnit of normalizedChar.split("")) {
normalizedIndexMap.push({ start, end });
}
originalIndex = end;
}
return { normalizedText, normalizedIndexMap };
};
const mergeHighlightRanges = (ranges: HighlightRange[]): HighlightRange[] => {
if (!ranges.length) {
return [];
}
const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
const mergedRanges: HighlightRange[] = [{ ...sortedRanges[0] }];
// Merge overlapping/adjacent ranges so the rendered marks stay minimal.
for (let i = 1; i < sortedRanges.length; i++) {
const previousRange = mergedRanges[mergedRanges.length - 1];
const currentRange = sortedRanges[i];
if (currentRange.start <= previousRange.end) {
previousRange.end = Math.max(previousRange.end, currentRange.end);
continue;
}
mergedRanges.push({ ...currentRange });
}
return mergedRanges;
};
/**
* Convert rendered `<mark>` nodes into DOM Ranges for `CSS.highlights`.
* We walk text nodes because Lit templates can place comment markers before
* text inside `<mark>`, so `firstChild` is not reliably the text node.
*/
const getHighlightRangesFromMarks = (root: ShadowRoot): Range[] => {
const ranges: Range[] = [];
root.querySelectorAll(HIGHLIGHT_MARK_SELECTOR).forEach((mark) => {
const textWalker = document.createTreeWalker(mark, NodeFilter.SHOW_TEXT);
let textNode = textWalker.nextNode();
while (textNode) {
const text = textNode.textContent;
if (text) {
const range = new Range();
range.setStart(textNode, 0);
range.setEnd(textNode, text.length);
ranges.push(range);
}
textNode = textWalker.nextNode();
}
});
return ranges;
};
const createHighlightStyle = (highlightName: string): string => css`
.ha-highlight {
/* Visual highlight comes from ::highlight(...), not the <mark> itself. */
background-color: transparent;
color: inherit;
border-radius: 0;
padding: 0;
box-shadow: none;
}
::highlight(${unsafeCSS(highlightName)}) {
background-color: var(
--ha-highlight-bg,
var(--ha-color-fill-primary-normal-hover)
);
color: var(--ha-highlight-color, var(--primary-text-color));
}
`.cssText;
const renderHighlightedParts = (
text: string,
ranges: HighlightRange[]
): (string | TemplateResult)[] => {
const parts: (string | TemplateResult)[] = [];
let previousIndex = 0;
for (const range of ranges) {
if (range.start > previousIndex) {
parts.push(text.slice(previousIndex, range.start));
}
parts.push(
html`<mark class="ha-highlight"
>${text.slice(range.start, range.end)}</mark
>`
);
previousIndex = range.end;
}
if (previousIndex < text.length) {
parts.push(text.slice(previousIndex));
}
return parts;
};
/**
* Search highlighting helper with two integration paths:
* 1) call `renderHighlightedText` + `applyFromMarks` when updates are driven
* by known state changes (like filter changes),
* 2) call `startAutoSyncFromMarks` when highlighted DOM can change
* independently of filter changes (like virtualized rows).
*/
export class SearchHighlight {
// `CSS.highlights` is document-global, not per shadow root.
// Each instance needs a unique key so that components do not overwrite each
// other's highlight ranges.
private static _nextHighlightId = 0;
// Fingerprints include Node identity, so map nodes to stable numeric IDs.
private static _nodeIds = new WeakMap<Node, number>();
private static _nextNodeId = 0;
// Cache the last apply inputs to avoid re-registering identical highlights.
private _lastCacheKey?: string;
private _lastFingerprint?: string;
private readonly _highlightName?: string;
private _autoSyncObserver?: MutationObserver;
private _autoSyncQueued = false;
private _autoSyncCacheKeyProvider?: () => string | null | undefined;
private _autoSyncObservedTarget?: Node;
public constructor(private readonly _root?: ShadowRoot) {
if (this._root) {
this._highlightName = `${HIGHLIGHT_NAME_PREFIX}-${SearchHighlight._nextHighlightId++}`;
this._addHighlightStyle();
}
}
/**
* Return text ranges that should be highlighted for the given query.
* Useful when the caller needs ranges without rendering `<mark>` output.
*/
public getHighlightRanges(
text: string,
query: string,
language?: string
): HighlightRange[] {
if (!text) {
return [];
}
const terms = tokenizeSearchQuery(query);
if (!terms.length) {
return [];
}
const { normalizedText, normalizedIndexMap } = buildNormalizedIndexMap(
text,
language
);
// Text can normalize to empty (for example, combining marks only).
if (!normalizedText) {
return [];
}
const ranges: HighlightRange[] = [];
for (const term of terms) {
const normalizedTerm = normalizeSearchText(term, language);
// Some tokens normalize to empty (like combining marks); skip them.
if (!normalizedTerm) {
continue;
}
let matchIndex = normalizedText.indexOf(normalizedTerm);
while (matchIndex !== -1) {
// Convert normalized-text match indexes back to original-text indexes.
// `indexOf` guarantees the full normalized term is within bounds, and
// we append one mapping item per normalized UTF-16 code unit.
const start = normalizedIndexMap[matchIndex]!.start;
const end =
normalizedIndexMap[matchIndex + normalizedTerm.length - 1]!.end;
ranges.push({ start, end });
matchIndex = normalizedText.indexOf(
normalizedTerm,
matchIndex + normalizedTerm.length
);
}
}
return mergeHighlightRanges(ranges);
}
/**
* Render plain text with matching segments wrapped in `<mark>`.
* `<mark>` nodes are used as stable anchors for range extraction.
*/
public renderHighlightedText(
text: string | null | undefined,
query: string | null | undefined,
language?: string
): HighlightedText {
if (!text) {
return text;
}
const ranges = this.getHighlightRanges(text, query ?? "", language);
if (!ranges.length) {
return text;
}
return renderHighlightedParts(text, ranges);
}
/**
* Read rendered `<mark>` nodes from the root and apply matching
* `CSS.highlights` ranges.
* `cacheKey` should represent the current query/filter used to build marks.
*/
public applyFromMarks(cacheKey?: string): void {
if (!this._root) {
return;
}
this.applyFromRanges(getHighlightRangesFromMarks(this._root), cacheKey);
}
/**
* Apply precomputed ranges directly to `CSS.highlights`.
* Use this when ranges are built outside this class.
* `cacheKey` should represent the inputs used to build `ranges`.
*/
public applyFromRanges(ranges: Range[], cacheKey?: string): void {
if (!this._root || !this._highlightName) {
return;
}
const highlightRegistry = globalThis.CSS?.highlights;
if (!highlightRegistry || typeof Highlight === "undefined") {
return;
}
if (!ranges.length) {
this.clear();
return;
}
const fingerprint = this._getRangesFingerprint(ranges);
// Skip writes only when both the caller key and concrete range positions
// are unchanged.
if (
cacheKey === this._lastCacheKey &&
fingerprint === this._lastFingerprint
) {
return;
}
this._lastCacheKey = cacheKey;
this._lastFingerprint = fingerprint;
highlightRegistry.set(this._highlightName, new Highlight(...ranges));
}
/**
* Auto-sync `CSS.highlights` from `<mark>` nodes whenever marked DOM changes.
* Use this for components where highlighted DOM can change without filter
* changes (for example, virtualized lists).
* `cacheKeyProvider` should return the current query/filter string.
* `observedTarget` allows callers to scope observation to a subtree.
*/
public startAutoSyncFromMarks(
cacheKeyProvider: () => string | null | undefined,
observedTarget?: Node
): void {
if (!this._root || !this._highlightName) {
return;
}
this._autoSyncCacheKeyProvider = cacheKeyProvider;
this._autoSyncObservedTarget = observedTarget ?? this._root;
if (!this._autoSyncObserver) {
this._autoSyncObserver = new MutationObserver((records) => {
if (
!records.some((record) => this._mutationAffectsHighlights(record))
) {
return;
}
this._queueAutoSyncFromMarks();
});
}
this._autoSyncObserver.disconnect();
this._autoSyncObserver.observe(this._autoSyncObservedTarget, {
childList: true,
subtree: true,
characterData: true,
});
this._queueAutoSyncFromMarks();
}
/**
* Stop auto-sync started via `startAutoSyncFromMarks`.
*/
public stopAutoSyncFromMarks(): void {
this._autoSyncObserver?.disconnect();
this._autoSyncQueued = false;
this._autoSyncCacheKeyProvider = undefined;
this._autoSyncObservedTarget = undefined;
}
public clear(): void {
if (!this._root) {
return;
}
globalThis.CSS?.highlights?.delete(this._highlightName!);
this._lastCacheKey = undefined;
this._lastFingerprint = undefined;
}
private _getNodeId(node: Node): number {
let nodeId = SearchHighlight._nodeIds.get(node);
if (nodeId !== undefined) {
return nodeId;
}
nodeId = SearchHighlight._nextNodeId++;
SearchHighlight._nodeIds.set(node, nodeId);
return nodeId;
}
/**
* Build a stable signature for a set of ranges so we can detect real range
* changes even when the count stays the same.
*/
private _getRangesFingerprint(ranges: Range[]): string {
return ranges
.map((range) => {
const startNodeId = this._getNodeId(range.startContainer);
const endNodeId = this._getNodeId(range.endContainer);
return `${startNodeId}:${range.startOffset}-${endNodeId}:${range.endOffset}`;
})
.join("|");
}
private _queueAutoSyncFromMarks(): void {
if (this._autoSyncQueued) {
return;
}
this._autoSyncQueued = true;
// Coalesce bursts of mutations into a single highlight recomputation.
queueMicrotask(() => {
this._autoSyncQueued = false;
if (!this._root || !(this._root.host as HTMLElement).isConnected) {
return;
}
const cacheKey = this._autoSyncCacheKeyProvider?.()?.trim();
if (!cacheKey) {
this.clear();
return;
}
this.applyFromMarks(cacheKey);
});
}
private _mutationAffectsHighlights(mutation: MutationRecord): boolean {
if (mutation.type === "characterData") {
return this._nodeContainsHighlightMark(mutation.target);
}
if (mutation.type !== "childList") {
return false;
}
if (this._nodeContainsHighlightMark(mutation.target)) {
return true;
}
for (const node of mutation.addedNodes) {
if (this._nodeContainsHighlightMark(node)) {
return true;
}
}
for (const node of mutation.removedNodes) {
if (this._nodeContainsHighlightMark(node)) {
return true;
}
}
return false;
}
/**
* Returns true when a node is a highlight mark, contains one, or is a text/comment
* node inside one. The text/comment case covers Lit marker nodes.
*/
private _nodeContainsHighlightMark(node: Node): boolean {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
return (
element.matches(HIGHLIGHT_MARK_SELECTOR) ||
Boolean(element.querySelector(HIGHLIGHT_MARK_SELECTOR))
);
}
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return Boolean(
(node as DocumentFragment).querySelector?.(HIGHLIGHT_MARK_SELECTOR)
);
}
if (
node.nodeType === Node.TEXT_NODE ||
node.nodeType === Node.COMMENT_NODE
) {
const parentElement = (node as ChildNode).parentElement;
return Boolean(parentElement?.closest(HIGHLIGHT_MARK_SELECTOR));
}
return false;
}
/**
* Inject marker styles and `::highlight()` theme colors.
*/
private _addHighlightStyle(): void {
if (!this._root || !this._highlightName) {
return;
}
const style = document.createElement("style");
style.textContent = createHighlightStyle(this._highlightName);
this._root.appendChild(style);
}
}

View File

@@ -1,13 +0,0 @@
import { stripDiacritics } from "./strip-diacritics";
/**
* Normalize text for search comparisons (case-insensitive + diacritics-insensitive).
*/
export const normalizeSearchText = (text: string, language?: string): string =>
stripDiacritics(text).toLocaleLowerCase(language);
/**
* Split a user query into whitespace-delimited search terms.
*/
export const splitSearchTerms = (query: string): string[] =>
query.trim().split(/\s+/).filter(Boolean);

View File

@@ -12,10 +12,6 @@ export type FormatEntityStateFunc = (
stateObj: HassEntity,
state?: string
) => string;
export type FormatEntityStateToPartsFunc = (
stateObj: HassEntity,
state?: string
) => ValuePart[];
export type FormatEntityAttributeValueFunc = (
stateObj: HassEntity,
attribute: string,
@@ -50,13 +46,12 @@ export const computeFormatFunctions = async (
sensorNumericDeviceClasses: string[]
): Promise<{
formatEntityState: FormatEntityStateFunc;
formatEntityStateToParts: FormatEntityStateToPartsFunc;
formatEntityAttributeValue: FormatEntityAttributeValueFunc;
formatEntityAttributeValueToParts: FormatEntityAttributeValueToPartsFunc;
formatEntityAttributeName: FormatEntityAttributeNameFunc;
formatEntityName: FormatEntityNameFunc;
}> => {
const { computeStateDisplay, computeStateToParts } =
const { computeStateDisplay } =
await import("../entity/compute_state_display");
const {
computeAttributeValueDisplay,
@@ -75,16 +70,6 @@ export const computeFormatFunctions = async (
entities,
state
),
formatEntityStateToParts: (stateObj, state) =>
computeStateToParts(
localize,
stateObj,
locale,
sensorNumericDeviceClasses,
config,
entities,
state
),
formatEntityAttributeValue: (stateObj, attribute, value) =>
computeAttributeValueDisplay(
localize,

View File

@@ -1,15 +1,3 @@
let isViewTransitionDisabled = false;
try {
isViewTransitionDisabled =
window.localStorage.getItem("disableViewTransition") === "true";
} catch {
// ignore
}
export const setViewTransitionDisabled = (disabled: boolean): void => {
isViewTransitionDisabled = disabled;
};
/**
* Executes a synchronous callback within a View Transition if supported, otherwise runs it directly.
*
@@ -26,7 +14,7 @@ export const setViewTransitionDisabled = (disabled: boolean): void => {
export const withViewTransition = (
callback: (viewTransitionAvailable: boolean) => void
): Promise<void> => {
if (!document.startViewTransition || isViewTransitionDisabled) {
if (!document.startViewTransition) {
callback(false);
return Promise.resolve();
}

View File

@@ -19,7 +19,6 @@ import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
@@ -28,7 +27,6 @@ import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
@@ -94,18 +92,10 @@ export class HaChartBase extends LitElement {
private _resizeAnimationDuration?: number;
private _suspendResize = false;
private _layoutTransitionActive = false;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => {
if (this.chart) {
if (this._suspendResize) {
this._shouldResizeChart = true;
return;
}
if (!this.chart.getZr().animation.isFinished()) {
this._shouldResizeChart = true;
} else {
@@ -123,11 +113,8 @@ export class HaChartBase extends LitElement {
private _originalZrFlush?: () => void;
private _pendingSetup = false;
public disconnectedCallback() {
super.disconnectedCallback();
this._pendingSetup = false;
while (this._listeners.length) {
this._listeners.pop()!();
}
@@ -139,13 +126,7 @@ export class HaChartBase extends LitElement {
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._pendingSetup = true;
afterNextRender(() => {
if (this.isConnected && this._pendingSetup) {
this._pendingSetup = false;
this._setupChart();
}
});
this._setupChart();
}
this._listeners.push(
@@ -200,26 +181,6 @@ export class HaChartBase extends LitElement {
() => window.removeEventListener("keyup", handleKeyUp)
);
}
const handleLayoutTransition: EventListener = (ev) => {
const event = ev as HASSDomEvent<HASSDomEvents["hass-layout-transition"]>;
this._layoutTransitionActive = Boolean(event.detail?.active);
this.toggleAttribute(
"layout-transition-active",
this._layoutTransitionActive
);
this._suspendResize = this._layoutTransitionActive;
if (!this._suspendResize) {
this._resizeChartIfNeeded();
}
};
window.addEventListener("hass-layout-transition", handleLayoutTransition);
this._listeners.push(() =>
window.removeEventListener(
"hass-layout-transition",
handleLayoutTransition
)
);
}
protected firstUpdated() {
@@ -1027,29 +988,19 @@ export class HaChartBase extends LitElement {
}
private _handleChartRenderFinished = () => {
this._resizeChartIfNeeded();
if (this._shouldResizeChart) {
this.chart?.resize({
animation:
this._reducedMotion ||
typeof this._resizeAnimationDuration !== "number"
? undefined
: { duration: this._resizeAnimationDuration },
});
this._shouldResizeChart = false;
this._resizeAnimationDuration = undefined;
}
};
private _resizeChartIfNeeded() {
if (!this.chart || !this._shouldResizeChart) {
return;
}
if (this._suspendResize) {
return;
}
if (!this.chart.getZr().animation.isFinished()) {
return;
}
this.chart.resize({
animation:
this._reducedMotion || typeof this._resizeAnimationDuration !== "number"
? undefined
: { duration: this._resizeAnimationDuration },
});
this._shouldResizeChart = false;
this._resizeAnimationDuration = undefined;
}
private _compareCustomLegendOptions(
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
@@ -1071,18 +1022,11 @@ export class HaChartBase extends LitElement {
display: block;
position: relative;
letter-spacing: normal;
overflow: visible;
}
:host([layout-transition-active]),
:host([layout-transition-active]) .container,
:host([layout-transition-active]) .chart-container {
overflow: hidden;
}
.container {
display: flex;
flex-direction: column;
position: relative;
overflow: visible;
}
.container.has-height {
max-height: var(--chart-max-height, 350px);
@@ -1090,7 +1034,6 @@ export class HaChartBase extends LitElement {
.chart-container {
width: 100%;
max-height: var(--chart-max-height, 350px);
overflow: visible;
}
.has-height .chart-container {
flex: 1;

View File

@@ -58,7 +58,7 @@ export class HaSankeyChart extends LitElement {
@property({ type: Boolean }) public vertical = false;
@property({ attribute: false }) public valueFormatter?: (
@property({ type: String, attribute: false }) public valueFormatter?: (
value: number
) => string;

View File

@@ -29,7 +29,7 @@ export class HaSunburstChart extends LitElement {
@property({ attribute: false }) public data?: SunburstNode;
@property({ attribute: false }) public valueFormatter?: (
@property({ type: String, attribute: false }) public valueFormatter?: (
value: number
) => string;

View File

@@ -50,16 +50,16 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public endTime!: Date;
@property({ attribute: false }) public paddingYAxis = 0;
@property({ attribute: false, type: Number }) public paddingYAxis = 0;
@property({ attribute: false }) public chartIndex?;
@property({ attribute: false, type: Number }) public chartIndex?;
@property({ attribute: "logarithmic-scale", type: Boolean })
public logarithmicScale = false;
@property({ attribute: false }) public minYAxis?: number;
@property({ attribute: false, type: Number }) public minYAxis?: number;
@property({ attribute: false }) public maxYAxis?: number;
@property({ attribute: false, type: Number }) public maxYAxis?: number;
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@@ -306,10 +306,7 @@ export class StateHistoryChartLine extends LitElement {
visualMap: this._visualMap,
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
confine: true,
appendTo: document.body,
formatter: this._renderTooltip,
},
};
@@ -719,18 +716,6 @@ export class StateHistoryChartLine extends LitElement {
// Add an entry for final values
pushData(endTime, prevValues);
// For sensors, append current state if viewing recent data
const now = new Date();
// allow 1s of leeway for "now"
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = this.hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([now, currentValue]);
}
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});

View File

@@ -47,9 +47,9 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public endTime!: Date;
@property({ attribute: false }) public paddingYAxis = 0;
@property({ attribute: false, type: Number }) public paddingYAxis = 0;
@property({ attribute: false }) public chartIndex?;
@property({ attribute: false, type: Number }) public chartIndex?;
@property({ attribute: "hide-reset-button", type: Boolean })
public hideResetButton?: boolean;
@@ -255,10 +255,7 @@ export class StateHistoryChartTimeline extends LitElement {
right: rtl ? labelWidth : 1,
},
tooltip: {
renderMode: "html",
position: "bottom",
align: "center",
confine: true,
appendTo: document.body,
formatter: this._renderTooltip,
},
};

View File

@@ -60,7 +60,7 @@ export class StateHistoryCharts extends LitElement {
@property({ type: Boolean, attribute: "up-to-now" }) public upToNow = false;
@property({ attribute: false }) public hoursToShow?: number;
@property({ attribute: false, type: Number }) public hoursToShow?: number;
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
@@ -73,9 +73,9 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "logarithmic-scale", type: Boolean })
public logarithmicScale = false;
@property({ attribute: false }) public minYAxis?: number;
@property({ attribute: false, type: Number }) public minYAxis?: number;
@property({ attribute: false }) public maxYAxis?: number;
@property({ attribute: false, type: Number }) public maxYAxis?: number;
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;

View File

@@ -62,14 +62,14 @@ export class StatisticsChart extends LitElement {
@property({ attribute: false }) public endTime?: Date;
@property({ attribute: false })
@property({ attribute: false, type: Array })
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
@property({ attribute: false }) public minYAxis?: number;
@property({ attribute: false, type: Number }) public minYAxis?: number;
@property({ attribute: false }) public maxYAxis?: number;
@property({ attribute: false, type: Number }) public maxYAxis?: number;
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@@ -335,10 +335,7 @@ export class StatisticsChart extends LitElement {
},
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
confine: true,
appendTo: document.body,
formatter: this._renderTooltip,
},
};
@@ -575,7 +572,6 @@ export class StatisticsChart extends LitElement {
let firstSum: number | null | undefined = null;
stats.forEach((stat) => {
const startDate = new Date(stat.start);
const endDate = new Date(stat.end);
if (prevDate === startDate) {
return;
}
@@ -605,66 +601,10 @@ export class StatisticsChart extends LitElement {
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(
startDate,
endDate.getTime() < endTime.getTime() ? endDate : endTime,
dataValues
);
pushData(startDate, new Date(stat.end), dataValues);
}
});
// Close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push(
this._transformDataValue([lastEndTime, ...lastValues[i]!])
);
});
}
// Append current state if viewing recent data
const now = new Date();
// allow 10m of leeway for "now", because stats are 5 minute aggregated
const isUpToNow = now.getTime() - endTime.getTime() <= 600000;
if (isUpToNow) {
// Skip external statistics (they have ":" in the ID)
if (!statistic_id.includes(":")) {
const stateObj = this.hass.states[statistic_id];
if (stateObj) {
const currentValue = parseFloat(stateObj.state);
if (
isFinite(currentValue) &&
!this._hiddenStats.has(statistic_id)
) {
// Then push the current state at now
statTypes.forEach((type, i) => {
const val: (number | null)[] = [];
if (type === "sum" || type === "change") {
// Skip cumulative types - need special calculation
val.push(null);
} else if (
type === bandTop &&
this.chartType === "line" &&
drawBands &&
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
// For band chart, current value is both min and max, so diff is 0
val.push(0);
val.push(currentValue);
} else {
val.push(currentValue);
}
statDataSets[i].data!.push(
this._transformDataValue([now, ...val])
);
});
}
}
}
}
// Concat two arrays
Array.prototype.push.apply(totalDataSets, statDataSets);
Array.prototype.push.apply(legendData, statLegendData);

View File

@@ -9,13 +9,10 @@ import { fireEvent } from "../../common/dom/fire_event";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-dialog-footer";
import "../ha-icon-button";
import { createCloseHeading } from "../ha-dialog";
import "../ha-list";
import "../ha-list-item";
import "../ha-sortable";
import "../ha-svg-icon";
import "../ha-dialog";
import type {
DataTableColumnContainer,
DataTableColumnData,
@@ -32,20 +29,13 @@ export class DialogDataTableSettings extends LitElement {
@state() private _hiddenColumns?: string[];
@state() private _open = false;
public showDialog(params: DataTableSettingsDialogParams) {
this._params = params;
this._columnOrder = params.columnOrder;
this._hiddenColumns = params.hiddenColumns;
this._open = true;
}
public closeDialog() {
this._open = false;
}
private _dialogClosed() {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -102,10 +92,12 @@ export class DialogDataTableSettings extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${localize("ui.components.data-table.settings.header")}
@closed=${this._dialogClosed}
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
localize("ui.components.data-table.settings.header")
)}
>
<ha-sortable
@item-moved=${this._columnMoved}
@@ -160,17 +152,15 @@ export class DialogDataTableSettings extends LitElement {
)}
</ha-list>
</ha-sortable>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._reset}
>${localize("ui.components.data-table.settings.restore")}</ha-button
>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${localize("ui.components.data-table.settings.done")}
</ha-button>
</ha-dialog-footer>
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this._reset}
>${localize("ui.components.data-table.settings.restore")}</ha-button
>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${localize("ui.components.data-table.settings.done")}
</ha-button>
</ha-dialog>
`;
}
@@ -292,9 +282,21 @@ export class DialogDataTableSettings extends LitElement {
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 500px;
--dialog-z-index: 10;
--dialog-content-padding: 0 8px;
}
@media all and (max-width: 451px) {
ha-dialog {
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: 250px;
--ha-dialog-border-radius: var(--ha-border-radius-4xl)
var(--ha-border-radius-4xl) var(--ha-border-radius-square)
var(--ha-border-radius-square);
--mdc-dialog-min-height: calc(100% - 250px);
--mdc-dialog-max-height: calc(100% - 250px);
}
}
ha-list-item {
--mdc-list-side-padding: 12px;
overflow: visible;

View File

@@ -13,11 +13,9 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import { SearchHighlight } from "../../common/string/search-highlight";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
@@ -132,9 +130,9 @@ export class HaDataTable extends LitElement {
// eslint-disable-next-line lit/no-native-attributes
@property({ type: String }) public id = "id";
@property({ attribute: false }) public noDataText?: string;
@property({ attribute: false, type: String }) public noDataText?: string;
@property({ attribute: false }) public searchLabel?: string;
@property({ attribute: false, type: String }) public searchLabel?: string;
@property({ type: Boolean, attribute: "no-label-float" })
public noLabelFloat? = false;
@@ -179,8 +177,6 @@ export class HaDataTable extends LitElement {
private _lastUpdate = 0;
private _searchHighlight?: SearchHighlight;
// @ts-ignore
@restoreScroll(".scroller") private _savedScrollPos?: number;
@@ -237,36 +233,21 @@ export class HaDataTable extends LitElement {
// Force update of location of rows
this._filteredData = [...this._filteredData];
}
// Re-attach observer when the element reconnects.
if (this.hasUpdated) {
this._updateSearchHighlightSync();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._searchHighlight?.stopAutoSyncFromMarks();
this._searchHighlight?.clear();
}
protected firstUpdated() {
this.updateComplete.then(() => this._calcTableHeight());
this._updateSearchHighlightSync();
}
protected updated(changedProperties: PropertyValues) {
if (changedProperties.has("_filter")) {
this._updateSearchHighlightSync();
}
protected updated() {
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
if (header) {
if (header.scrollWidth > header.clientWidth) {
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
} else {
this.style.removeProperty("--table-row-width");
}
if (!header) {
return;
}
if (header.scrollWidth > header.clientWidth) {
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
} else {
this.style.removeProperty("--table-row-width");
}
}
@@ -638,12 +619,7 @@ export class HaDataTable extends LitElement {
${column.template
? column.template(row)
: narrow && column.main
? html`<div class="primary">
${this._renderValueWithHighlight(
row[key],
column.filterable
)}
</div>
? html`<div class="primary">${row[key]}</div>
<div class="secondary">
${Object.entries(columns)
.filter(
@@ -660,22 +636,16 @@ export class HaDataTable extends LitElement {
.map(
([key2, column2], i) =>
html`${i !== 0
? STRINGS_SEPARATOR_DOT
: nothing}${this._renderCellValue(
column2,
key2,
row
)}`
? " · "
: nothing}${column2.template
? column2.template(row)
: row[key2]}`
)}
</div>
${column.extraTemplate
? column.extraTemplate(row)
: nothing}`
: html`${this._renderCellValue(
column,
key,
row
)}${column.extraTemplate
: html`${row[key]}${column.extraTemplate
? column.extraTemplate(row)
: nothing}`}
</div>
@@ -685,69 +655,6 @@ export class HaDataTable extends LitElement {
`;
};
private _renderCellValue(
column: DataTableColumnData,
key: string,
row: DataTableRowData
) {
if (column.template) {
return column.template(row);
}
return this._renderValueWithHighlight(row[key], column.filterable);
}
private _renderValueWithHighlight(
value: unknown,
filterable?: boolean
): unknown {
if (!filterable) {
return value;
}
const filter = this._filter.trim();
if (!filter) {
return value;
}
if (typeof value !== "string" && typeof value !== "number") {
return value;
}
const text = String(value);
return this._getSearchHighlight().renderHighlightedText(
text,
filter,
this.hass.locale.language
);
}
private _getSearchHighlight(): SearchHighlight {
if (!this._searchHighlight) {
this._searchHighlight = new SearchHighlight(
this.renderRoot as ShadowRoot
);
}
return this._searchHighlight;
}
private _updateSearchHighlightSync(): void {
if (!this._filter.trim()) {
this._searchHighlight?.stopAutoSyncFromMarks();
this._searchHighlight?.clear();
return;
}
const observedTarget =
this.renderRoot.querySelector("lit-virtualizer") ||
(this.renderRoot as ShadowRoot);
this._getSearchHighlight().startAutoSyncFromMarks(
() => this._filter,
observedTarget
);
}
private async _sortFilterData() {
const startTime = new Date().getTime();
const timeBetweenUpdate = startTime - this._lastUpdate;
@@ -1179,12 +1086,9 @@ export class HaDataTable extends LitElement {
}
.mdc-data-table__row.empty-row {
height: max(
var(
--data-table-empty-row-height,
var(--data-table-row-height, 52px)
),
var(--safe-area-inset-bottom, 0px)
height: var(
--data-table-empty-row-height,
var(--data-table-row-height, 52px)
);
}
@@ -1288,7 +1192,6 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell--numeric {
text-align: var(--float-end);
direction: ltr;
}
.mdc-data-table__cell--icon {

View File

@@ -3,10 +3,7 @@ import type { FuseOptionKey, IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import {
normalizeSearchText,
splitSearchTerms,
} from "../../common/string/search-query";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -50,10 +47,10 @@ const getSearchableValue = (
const stringValues = value
.filter((item) => item != null && typeof item !== "object")
.map(String);
return normalizeSearchText(stringValues.join(" "));
return stripDiacritics(stringValues.join(" ").toLowerCase());
}
return normalizeSearchText(String(value));
return stripDiacritics(String(value).toLowerCase());
};
/** Filters data using exact substring matching (all terms must match). */
@@ -144,7 +141,7 @@ const filterData = (
columns: SortableColumnContainer,
filter: string
): DataTableRowData[] => {
const normalizedFilter = normalizeSearchText(filter).trim();
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
if (!normalizedFilter) {
return data;
@@ -156,7 +153,7 @@ const filterData = (
return data;
}
const terms = splitSearchTerms(normalizedFilter);
const terms = normalizedFilter.split(/\s+/);
// First, try exact substring matching
const exactMatches = filterDataExact(data, filterKeys, terms);

View File

@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -11,8 +11,10 @@ import {
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant } from "../../types";
import "../ha-generic-picker";
import "../ha-md-select";
import "../ha-md-select-option";
import type { PickerValueRenderer } from "../ha-picker-field";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
@@ -190,7 +192,7 @@ export abstract class HaDeviceAutomationPicker<
this._renderEmpty = false;
}
private _automationChanged(ev: ValueChangedEvent<string>) {
private _automationChanged(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value || NO_AUTOMATION_KEY === value) {
@@ -215,4 +217,10 @@ export abstract class HaDeviceAutomationPicker<
delete value.metadata;
fireEvent(this, "value-changed", { value });
}
static styles = css`
ha-select {
display: block;
}
`;
}

View File

@@ -48,7 +48,7 @@ export class HaDevicePicker extends LitElement {
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: false }) public createDomains?: string[];
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
* Show only devices with entities from specific domains.

View File

@@ -76,7 +76,7 @@ class HaEntitiesPicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false }) public createDomains?: string[];
@property({ attribute: false, type: Array }) public createDomains?: string[];
@property({ type: Boolean })
public reorder = false;

View File

@@ -58,7 +58,7 @@ export class HaEntityPicker extends LitElement {
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: false }) public createDomains?: string[];
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
* Show entities from specific domains.

View File

@@ -9,7 +9,16 @@ import secondsToDuration from "../../common/datetime/seconds_to_duration";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import {
formatNumber,
getNumberFormatOptions,
isNumericState,
} from "../../common/number/format_number";
import {
isUnavailableState,
UNAVAILABLE,
UNKNOWN,
} from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types";
@@ -171,11 +180,16 @@ export class HaStateLabelBadge extends LitElement {
}
// eslint-disable-next-line: disable=no-fallthrough
default:
return isUnavailableState(entityState.state)
return entityState.state === UNKNOWN ||
entityState.state === UNAVAILABLE
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
)?.value;
: isNumericState(entityState)
? formatNumber(
entityState.state,
this.hass!.locale,
getNumberFormatOptions(entityState, entry)
)
: this.hass!.formatEntityState(entityState);
}
}
@@ -224,11 +238,7 @@ export class HaStateLabelBadge extends LitElement {
if (domain === "timer") {
return secondsToDuration(_timerTimeRemaining);
}
return (
this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "unit"
)?.value || null
);
return entityState.attributes.unit_of_measurement || null;
}
private _clearInterval() {

View File

@@ -1,5 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiChartLine, mdiHelpCircleOutline, mdiShape } from "@mdi/js";
import { mdiChartLine, mdiHelpCircle, 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";
@@ -78,7 +78,7 @@ export class HaStatisticPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property({ attribute: false })
@property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[];
@property({ attribute: false }) public helpMissingEntityUrl =
@@ -163,7 +163,7 @@ export class HaStatisticPicker extends LitElement {
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
icon_path: mdiHelpCircleOutline,
icon_path: mdiHelpCircle,
},
];
}

View File

@@ -11,7 +11,7 @@ class HaStatisticsPicker extends LitElement {
@property({ type: Array }) public value?: string[];
@property({ attribute: false }) public statisticIds?: string[];
@property({ attribute: false, type: Array }) public statisticIds?: string[];
@property({ attribute: "statistic-types" })
public statisticTypes?: "mean" | "sum";

View File

@@ -6,8 +6,8 @@ import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-dialog-header";
import "./ha-icon-button";
import "./ha-dialog";
import type { DialogWidth } from "./ha-dialog";
import "./ha-wa-dialog";
import type { DialogWidth } from "./ha-wa-dialog";
type DialogSheetMode = "dialog" | "bottom-sheet";
@@ -18,11 +18,10 @@ type DialogSheetMode = "dialog" | "bottom-sheet";
* @extends {LitElement}
*
* @summary
* A responsive dialog component that automatically switches between a full dialog (ha-dialog)
* A responsive dialog component that automatically switches between a full dialog (ha-wa-dialog)
* and a bottom sheet (ha-bottom-sheet) based on screen size. Uses dialog mode on larger screens
* (>870px width and >500px height) and bottom sheet mode on smaller screens or mobile devices.
*
* @slot header - Replace the entire header area.
* @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerTitle - Custom title content (used when header-title is not set).
* @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set).
@@ -36,19 +35,15 @@ type DialogSheetMode = "dialog" | "bottom-sheet";
* @cssprop --ha-dialog-hide-duration - Hide animation duration (dialog mode only).
*
* @attr {boolean} open - Controls the dialog/sheet open state.
* @attr {("alert"|"standard")} type - Dialog type (dialog mode only). Defaults to "standard".
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset (dialog mode only). Defaults to "medium".
* @attr {boolean} prevent-scrim-close - Prevents closing by clicking the scrim/overlay.
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} flexcontent - Makes the content body a flex container.
* @attr {boolean} without-header - Hides the default header.
* @attr {boolean} allow-mode-change - When set, the component can switch between dialog and bottom-sheet modes as the viewport changes.
* @attr {boolean} block-mode-change - When set, the mode is determined at mount time based on the current screen size, but subsequent mode changes are blocked. Useful for preventing forms from resetting when the viewport size changes.
*
* @event opened - Fired when the dialog/sheet is shown.
* @event opened - Fired when the dialog/sheet is shown (dialog mode only).
* @event closed - Fired after the dialog/sheet is hidden.
* @event after-show - Fired after show animation completes.
* @event after-show - Fired after show animation completes (dialog mode only).
*
* @remarks
* **Responsive Behavior:**
@@ -56,9 +51,9 @@ type DialogSheetMode = "dialog" | "bottom-sheet";
* Dialog mode is used for screens wider than 870px and taller than 500px.
* Bottom sheet mode is used for mobile devices and smaller screens.
*
* By default, the mode is determined once at mount time and is then kept stable to avoid state
* loss (like form resets) during viewport changes. Set `allow-mode-change` to opt into live
* mode switching while the dialog is open.
* When `block-mode-change` is set, the mode is determined once at mount time based on the initial
* screen size. Subsequent viewport size changes will not trigger mode switches, which is useful
* for preventing form resets or other state loss when users resize their browser window.
*
* **Focus Management:**
* To automatically focus an element when opened, add the `autofocus` attribute to it.
@@ -78,15 +73,9 @@ export class HaAdaptiveDialog extends LitElement {
@property({ type: Boolean, reflect: true })
public open = false;
@property({ reflect: true })
public type: "alert" | "standard" = "standard";
@property({ type: String, reflect: true, attribute: "width" })
public width: DialogWidth = "medium";
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false;
@property({ attribute: "header-title" })
public headerTitle?: string;
@@ -96,15 +85,12 @@ export class HaAdaptiveDialog extends LitElement {
@property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below";
@property({ type: Boolean, attribute: "allow-mode-change" })
public allowModeChange = false;
@property({ type: Boolean, attribute: "block-mode-change" })
public blockModeChange = false;
@property({ type: Boolean, attribute: "without-header" })
public withoutHeader = false;
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@state() private _mode: DialogSheetMode = "dialog";
private _unsubMediaQuery?: () => void;
@@ -116,7 +102,7 @@ export class HaAdaptiveDialog extends LitElement {
this._unsubMediaQuery = listenMediaQuery(
"(max-width: 870px), (max-height: 500px)",
(matches) => {
if (!this._modeSet || this.allowModeChange) {
if (!this._modeSet || !this.blockModeChange) {
this._mode = matches ? "bottom-sheet" : "dialog";
this._modeSet = true;
}
@@ -134,50 +120,33 @@ export class HaAdaptiveDialog extends LitElement {
render() {
if (this._mode === "bottom-sheet") {
return html`
<ha-bottom-sheet
.ariaLabelledBy=${this.ariaLabelledBy ||
(this.headerTitle !== undefined ? "ha-dialog-title" : undefined)}
.ariaDescribedBy=${this.ariaDescribedBy}
.flexContent=${this.flexContent}
.hass=${this.hass}
.open=${this.open}
.preventScrimClose=${this.preventScrimClose}
>
<ha-bottom-sheet .open=${this.open} flexcontent>
${!this.withoutHeader
? html`
<slot name="header" slot="header">
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
.label=${this.hass?.localize("ui.common.close") ??
"Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle !== undefined
? html`<span
slot="title"
class="title"
id="ha-dialog-title"
>
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle"
>${this.headerSubtitle}</span
>`
: html`<slot
name="headerSubtitle"
slot="subtitle"
></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
? html`<ha-dialog-header
slot="header"
.subtitlePosition=${this.headerSubtitlePosition}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-drawer="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
`
${this.headerTitle !== undefined
? html`<span
slot="title"
class="title"
id="ha-wa-dialog-title"
>
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>`
: nothing}
<slot></slot>
<slot name="footer" slot="footer"></slot>
@@ -186,18 +155,16 @@ export class HaAdaptiveDialog extends LitElement {
}
return html`
<ha-dialog
<ha-wa-dialog
.hass=${this.hass}
.open=${this.open}
.type=${this.type}
.width=${this.width}
.preventScrimClose=${this.preventScrimClose}
.ariaLabelledBy=${this.ariaLabelledBy}
.ariaDescribedBy=${this.ariaDescribedBy}
.headerTitle=${this.headerTitle}
.headerSubtitle=${this.headerSubtitle}
.headerSubtitlePosition=${this.headerSubtitlePosition}
.flexContent=${this.flexContent}
flexcontent
.withoutHeader=${this.withoutHeader}
>
<slot name="headerNavigationIcon" slot="headerNavigationIcon">
@@ -212,7 +179,7 @@ export class HaAdaptiveDialog extends LitElement {
<slot name="headerActionItems" slot="headerActionItems"></slot>
<slot></slot>
<slot name="footer" slot="footer"></slot>
</ha-dialog>
</ha-wa-dialog>
`;
}
@@ -224,12 +191,6 @@ export class HaAdaptiveDialog extends LitElement {
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
);
--ha-bottom-sheet-padding: 0 var(--safe-area-inset-right)
var(--safe-area-inset-bottom) var(--safe-area-inset-left);
--ha-bottom-sheet-content-padding: var(
--dialog-content-padding,
0 var(--ha-space-6) var(--ha-space-6) var(--ha-space-6)
);
}
`,
];

View File

@@ -5,7 +5,7 @@ import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
import { haStyle } from "../resources/styles";
import "./ha-md-list-item";
import "./ha-settings-row";
import "./ha-switch";
import "./ha-tooltip";
import type { HaSwitch } from "./ha-switch";
@@ -33,80 +33,105 @@ export class HaAnalytics extends LitElement {
const baseEnabled = !loading && this.analytics!.preferences.base;
return html`
<ha-md-list-item>
<span slot="headline"
>${this.localize(
<ha-settings-row>
<span slot="heading" data-for="base">
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
)}
</span>
<span slot="description" data-for="base">
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.description`
)}</span
>
)}
</span>
<ha-switch
slot="end"
@change=${this._handleRowClick}
.checked=${!!baseEnabled}
.preference=${"base"}
.disabled=${loading}
name="base"
></ha-switch>
</ha-md-list-item>
>
</ha-switch>
</ha-settings-row>
${ADDITIONAL_PREFERENCES.map(
(preference) => html`
<ha-md-list-item>
<span slot="headline"
>${this.localize(
<ha-settings-row>
<span slot="heading" data-for=${preference}>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
)}
</span>
<span slot="description" data-for=${preference}>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.description`
)}</span
>
<ha-switch
slot="end"
.id="switch-${preference}"
@change=${this._handleRowClick}
.checked=${!!this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
></ha-switch>
${baseEnabled
? nothing
: html`<ha-tooltip .for="switch-${preference}" placement="right">
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>`}
</ha-md-list-item>
)}
</span>
<span>
<ha-switch
.id="switch-${preference}"
@change=${this._handleRowClick}
.checked=${!!this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
>
</ha-switch>
${baseEnabled
? nothing
: html`<ha-tooltip
.for="switch-${preference}"
placement="right"
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>`}
</span>
</ha-settings-row>
`
)}
<ha-md-list-item>
<span slot="headline"
>${this.localize(
<ha-settings-row>
<span slot="heading" data-for="diagnostics">
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
)}
</span>
<span slot="description" data-for="diagnostics">
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.description`
)}</span
>
)}
</span>
<ha-switch
slot="end"
@change=${this._handleRowClick}
.checked=${!!this.analytics?.preferences.diagnostics}
.preference=${"diagnostics"}
.disabled=${loading}
name="diagnostics"
></ha-switch>
</ha-md-list-item>
>
</ha-switch>
</ha-settings-row>
`;
}
protected updated(changedProps) {
super.updated(changedProps);
this.shadowRoot!.querySelectorAll("*[data-for]").forEach((el) => {
const forEl = (el as HTMLElement).dataset.for;
delete (el as HTMLElement).dataset.for;
el.addEventListener("click", () => {
const toFocus = this.shadowRoot!.querySelector(
`*[name=${forEl}]`
) as HTMLElement | null;
if (toFocus) {
toFocus.focus();
toFocus.click();
}
});
});
}
private _handleRowClick(ev: Event) {
const target = ev.currentTarget as HaSwitch;
const preference = (target as any).preference;
@@ -139,10 +164,13 @@ export class HaAnalytics extends LitElement {
color: var(--error-color);
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
ha-settings-row {
padding: 0;
}
span[slot="heading"],
span[slot="description"] {
cursor: pointer;
}
`,
];

View File

@@ -12,7 +12,6 @@ import {
state as litState,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { SearchHighlight } from "../common/string/search-highlight";
interface State {
bold: boolean;
@@ -34,8 +33,6 @@ export class HaAnsiToHtml extends LitElement {
@litState() private _filter = "";
private _searchHighlight?: SearchHighlight;
protected render(): TemplateResult {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
@@ -49,13 +46,9 @@ export class HaAnsiToHtml extends LitElement {
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._searchHighlight?.clear();
}
static styles = css`
pre {
overflow-x: auto;
margin: 0;
}
pre.wrap {
@@ -122,6 +115,11 @@ export class HaAnsiToHtml extends LitElement {
.bg-white {
background-color: rgb(204, 204, 204);
}
::highlight(search-results) {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
`;
/**
@@ -326,29 +324,30 @@ export class HaAnsiToHtml extends LitElement {
this._filter = filter;
const lines = this.shadowRoot?.querySelectorAll("div") || [];
let numberOfFoundLines = 0;
const filterLower = filter.toLowerCase();
if (!filter) {
lines.forEach((line) => {
line.style.display = "";
});
numberOfFoundLines = lines.length;
this._searchHighlight?.clear();
if (CSS.highlights) {
CSS.highlights.delete("search-results");
}
} else {
const highlightRanges: Range[] = [];
lines.forEach((line) => {
if (!line.textContent?.toLowerCase().includes(filterLower)) {
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
line.style.display = "none";
} else {
line.style.display = "";
numberOfFoundLines++;
if (line.firstChild !== null && line.textContent) {
if (CSS.highlights && line.firstChild !== null && line.textContent) {
const spansOfLine = line.querySelectorAll("span");
spansOfLine.forEach((span) => {
const text = span.textContent.toLowerCase();
const indices: number[] = [];
let startPos = 0;
while (startPos < text.length) {
const index = text.indexOf(filterLower, startPos);
const index = text.indexOf(filter.toLowerCase(), startPos);
if (index === -1) break;
indices.push(index);
startPos = index + filter.length;
@@ -364,11 +363,8 @@ export class HaAnsiToHtml extends LitElement {
}
}
});
if (this.shadowRoot) {
this._getSearchHighlight(this.shadowRoot).applyFromRanges(
highlightRanges,
filter
);
if (CSS.highlights) {
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
}
}
@@ -380,13 +376,6 @@ export class HaAnsiToHtml extends LitElement {
this._pre.innerHTML = "";
}
}
private _getSearchHighlight(root: ShadowRoot): SearchHighlight {
if (!this._searchHighlight) {
this._searchHighlight = new SearchHighlight(root);
}
return this._searchHighlight;
}
}
declare global {

View File

@@ -163,7 +163,7 @@ export class HaAreaPicker extends LitElement {
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.area-picker.add_new_suggestion",
"ui.components.area-picker.add_new_sugestion",
{
name: searchString,
}

View File

@@ -9,7 +9,7 @@ import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-items-display-editor";
@@ -200,7 +200,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
fireEvent(this, "value-changed", { value: newValue });
}
private async _areaDisplayChanged(ev: ValueChangedEvent<DisplayValue>) {
private async _areaDisplayChanged(ev: CustomEvent<{ value: DisplayValue }>) {
ev.stopPropagation();
const value = ev.detail.value;
const currentFloorId = (ev.currentTarget as any).floorId;

View File

@@ -1,9 +1,8 @@
import { mdiAlertCircle, mdiMicrophone, mdiSend } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { haStyleScrollbar } from "../resources/styles";
import { supportsFeature } from "../common/entity/supports-feature";
import {
runAssistPipeline,
@@ -37,7 +36,7 @@ export class HaAssistChat extends LitElement {
@property({ type: Boolean, attribute: "disable-speech" })
public disableSpeech = false;
@property({ attribute: false })
@property({ type: Boolean, attribute: false })
public startListening?: boolean;
@query("#message-input") private _messageInput!: HaTextField;
@@ -115,7 +114,7 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html`
<div class="messages ha-scrollbar">
<div class="messages">
${controlHA
? nothing
: html`
@@ -586,167 +585,154 @@ export class HaAssistChat extends LitElement {
return progress;
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
ha-alert {
margin-bottom: var(--ha-space-2);
}
ha-textfield {
display: block;
}
.messages {
flex: 1 1 400px;
display: block;
box-sizing: border-box;
overflow-y: auto;
min-height: 0;
max-height: 100%;
display: flex;
flex-direction: column;
padding: 0 var(--ha-space-3) var(--ha-space-4);
}
.input {
padding: var(--ha-space-1) var(--ha-space-4) var(--ha-space-6);
}
.spacer {
flex: 1;
}
.message {
font-size: var(--ha-font-size-l);
clear: both;
max-width: -webkit-fill-available;
overflow-wrap: break-word;
scroll-margin-top: var(--ha-space-6);
margin: var(--ha-space-2) 0;
padding: var(--ha-space-2);
border-radius: var(--ha-border-radius-xl);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.message {
font-size: var(--ha-font-size-l);
}
}
.message.user {
margin-left: var(--ha-space-6);
margin-inline-start: var(--ha-space-6);
margin-inline-end: initial;
align-self: flex-end;
border-bottom-right-radius: 0px;
--markdown-link-color: var(--text-primary-color);
background-color: var(
--chat-background-color-user,
var(--primary-color)
);
color: var(--text-primary-color);
direction: var(--direction);
}
.message.hass {
margin-right: var(--ha-space-6);
margin-inline-end: var(--ha-space-6);
margin-inline-start: initial;
align-self: flex-start;
border-bottom-left-radius: 0px;
background-color: var(
--chat-background-color-hass,
var(--secondary-background-color)
);
static styles = css`
:host {
flex: 1;
display: flex;
flex-direction: column;
}
ha-alert {
margin-bottom: 8px;
}
ha-textfield {
display: block;
}
.messages {
flex: 1;
display: block;
box-sizing: border-box;
overflow-y: auto;
max-height: 100%;
display: flex;
flex-direction: column;
padding: 0 12px 16px;
}
.spacer {
flex: 1;
}
.message {
font-size: var(--ha-font-size-l);
clear: both;
max-width: -webkit-fill-available;
overflow-wrap: break-word;
scroll-margin-top: 24px;
margin: 8px 0;
padding: 8px;
border-radius: var(--ha-border-radius-xl);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.message {
font-size: var(--ha-font-size-l);
}
}
.message.user {
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
align-self: flex-end;
border-bottom-right-radius: 0px;
--markdown-link-color: var(--text-primary-color);
background-color: var(--chat-background-color-user, var(--primary-color));
color: var(--text-primary-color);
direction: var(--direction);
}
.message.hass {
margin-right: 24px;
margin-inline-end: 24px;
margin-inline-start: initial;
align-self: flex-start;
border-bottom-left-radius: 0px;
background-color: var(
--chat-background-color-hass,
var(--secondary-background-color)
);
color: var(--primary-text-color);
direction: var(--direction);
}
.message.error {
background-color: var(--error-color);
color: var(--text-primary-color);
}
ha-markdown {
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
--markdown-table-border-color: var(--divider-color);
--markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color);
--markdown-list-indent: 1.15em;
&:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
}
.bouncer {
width: 48px;
height: 48px;
position: absolute;
}
.double-bounce1,
.double-bounce2 {
width: 48px;
height: 48px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--primary-color);
opacity: 0.2;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
@-webkit-keyframes sk-bounce {
0%,
100% {
-webkit-transform: scale(0);
}
50% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
color: var(--primary-text-color);
direction: var(--direction);
}
.message.error {
background-color: var(--error-color);
color: var(--text-primary-color);
}
ha-markdown {
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
--markdown-table-border-color: var(--divider-color);
--markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color);
--markdown-list-indent: 1.15em;
&:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
}
.bouncer {
width: 48px;
height: 48px;
position: absolute;
}
.double-bounce1,
.double-bounce2 {
width: 48px;
height: 48px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--primary-color);
opacity: 0.2;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
@-webkit-keyframes sk-bounce {
0%,
100% {
-webkit-transform: scale(0);
}
50% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
.listening-icon {
position: relative;
color: var(--secondary-text-color);
margin-right: -24px;
margin-inline-end: -24px;
margin-inline-start: initial;
direction: var(--direction);
transform: scaleX(var(--scale-direction));
}
.listening-icon {
position: relative;
color: var(--secondary-text-color);
margin-right: -24px;
margin-inline-end: -24px;
margin-inline-start: initial;
direction: var(--direction);
transform: scaleX(var(--scale-direction));
}
.listening-icon[active] {
color: var(--primary-color);
}
.listening-icon[active] {
color: var(--primary-color);
}
.unsupported {
color: var(--error-color);
position: absolute;
--mdc-icon-size: 16px;
right: 5px;
inset-inline-end: 5px;
inset-inline-start: initial;
top: 0px;
}
`,
];
}
.unsupported {
color: var(--error-color);
position: absolute;
--mdc-icon-size: 16px;
right: 5px;
inset-inline-end: 5px;
inset-inline-start: initial;
top: 0px;
}
`;
}
declare global {

View File

@@ -2,12 +2,14 @@ import type { PropertyValueMap } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { formatLanguageCode } from "../common/language/format_language";
import type { AssistPipeline } from "../data/assist_pipeline";
import { listAssistPipelines } from "../data/assist_pipeline";
import type { HomeAssistant } from "../types";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
import "./ha-list-item";
import "./ha-select";
import type { HaSelect } from "./ha-select";
const PREFERRED = "preferred";
const LAST_USED = "last_used";
@@ -39,31 +41,6 @@ export class HaAssistPipelinePicker extends LitElement {
return nothing;
}
const value = this.value ?? this._default;
const options: HaSelectOption[] = [
{
value: PREFERRED,
label: this.hass.localize("ui.components.pipeline-picker.preferred", {
preferred: this._pipelines.find(
(pipeline) => pipeline.id === this._preferredPipeline
)?.name,
}),
},
];
if (this.includeLastUsed) {
options.unshift({
value: LAST_USED,
label: this.hass.localize("ui.components.pipeline-picker.last_used"),
});
}
options.push(
...this._pipelines.map((pipeline) => ({
value: pipeline.id,
label: `${pipeline.name} (${formatLanguageCode(pipeline.language, this.hass.locale)})`,
}))
);
return html`
<ha-select
.label=${this.label ||
@@ -72,8 +49,33 @@ export class HaAssistPipelinePicker extends LitElement {
.required=${this.required}
.disabled=${this.disabled}
@selected=${this._changed}
.options=${options}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
>
${this.includeLastUsed
? html`
<ha-list-item .value=${LAST_USED}>
${this.hass!.localize(
"ui.components.pipeline-picker.last_used"
)}
</ha-list-item>
`
: null}
<ha-list-item .value=${PREFERRED}>
${this.hass!.localize("ui.components.pipeline-picker.preferred", {
preferred: this._pipelines.find(
(pipeline) => pipeline.id === this._preferredPipeline
)?.name,
})}
</ha-list-item>
${this._pipelines.map(
(pipeline) =>
html`<ha-list-item .value=${pipeline.id}>
${pipeline.name}
(${formatLanguageCode(pipeline.language, this.hass.locale)})
</ha-list-item>`
)}
</ha-select>
`;
}
@@ -94,17 +96,17 @@ export class HaAssistPipelinePicker extends LitElement {
}
`;
private _changed(ev: HaSelectSelectEvent): void {
const value = ev.detail.value;
private _changed(ev): void {
const target = ev.target as HaSelect;
if (
!this.hass ||
value === "" ||
value === this.value ||
(this.value === undefined && value === this._default)
target.value === "" ||
target.value === this.value ||
(this.value === undefined && target.value === this._default)
) {
return;
}
this.value = value === this._default ? undefined : value;
this.value = target.value === this._default ? undefined : target.value;
fireEvent(this, "value-changed", { value: this.value });
}
}

View File

@@ -4,9 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import type { HaSelectSelectEvent } from "./ha-select";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-list-item";
import "./ha-select";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -259,10 +260,14 @@ export class HaBaseTimeInput extends LitElement {
.required=${this.required}
.value=${this.amPm}
.disabled=${this.disabled}
.name=${"amPm"}
name="amPm"
naturalMenuWidth
fixedMenuPosition
@selected=${this._valueChanged}
.options=${["AM", "PM"]}
@closed=${stopPropagation}
>
<ha-list-item value="AM">AM</ha-list-item>
<ha-list-item value="PM">PM</ha-list-item>
</ha-select>`}
</div>
${this.helper
@@ -277,12 +282,10 @@ export class HaBaseTimeInput extends LitElement {
fireEvent(this, "value-changed");
}
private _valueChanged(ev: InputEvent | HaSelectSelectEvent): void {
private _valueChanged(ev: InputEvent) {
const textField = ev.currentTarget as HaTextField;
this[textField.name] =
textField.name === "amPm"
? (ev as HaSelectSelectEvent).detail.value
: Number(textField.value);
textField.name === "amPm" ? textField.value : Number(textField.value);
const value: TimeChangedEvent = {
hours: this.hours,
minutes: this.minutes,
@@ -363,6 +366,10 @@ export class HaBaseTimeInput extends LitElement {
ha-textfield:last-child {
--text-field-border-top-right-radius: var(--mdc-shape-medium);
}
ha-select {
--mdc-shape-small: 0;
width: 85px;
}
:host([clearable]) .mdc-select__anchor {
padding-inline-end: var(--select-selected-text-padding-end, 12px);
}

View File

@@ -2,11 +2,12 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { stringCompare } from "../common/string/compare";
import type { Blueprint, BlueprintDomain, Blueprints } from "../data/blueprint";
import { fetchBlueprints } from "../data/blueprint";
import type { HomeAssistant } from "../types";
import type { HaSelectSelectEvent } from "./ha-select";
import "./ha-list-item";
import "./ha-select";
@customElement("ha-blueprint-picker")
@@ -54,16 +55,20 @@ class HaBluePrintPicker extends LitElement {
<ha-select
.label=${this.label ||
this.hass.localize("ui.components.blueprint-picker.select_blueprint")}
fixedMenuPosition
naturalMenuWidth
.value=${this.value}
.disabled=${this.disabled}
@selected=${this._blueprintChanged}
.options=${this._processedBlueprints(this.blueprints).map(
(blueprint) => ({
value: blueprint.path,
label: blueprint.name,
})
)}
@closed=${stopPropagation}
>
${this._processedBlueprints(this.blueprints).map(
(blueprint) => html`
<ha-list-item .value=${blueprint.path}>
${blueprint.name}
</ha-list-item>
`
)}
</ha-select>
`;
}
@@ -77,8 +82,8 @@ class HaBluePrintPicker extends LitElement {
}
}
private _blueprintChanged(ev: HaSelectSelectEvent) {
const newValue = ev.detail.value;
private _blueprintChanged(ev) {
const newValue = ev.target.value;
if (newValue !== this.value) {
this.value = newValue;

View File

@@ -1,34 +1,19 @@
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import type WaDrawer from "@home-assistant/webawesome/dist/components/drawer/drawer";
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@customElement("ha-bottom-sheet")
export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@property({ attribute: "aria-describedby" })
public ariaDescribedBy?: string;
@property({ type: Boolean }) public open = false;
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false;
@state() private _drawerOpen = false;
@query("#drawer") private _drawer!: HTMLElement;
@@ -43,78 +28,15 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
private _isDragging = false;
private _escapePressed = false;
private _handleShow = async () => {
this._drawerOpen = true;
this.open = true;
fireEvent(this, "opened");
await this.updateComplete;
requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass)) {
const element = this.renderRoot.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-bottom-sheet-autofocus";
}
this.hass.auth.external?.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
(
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
)?.focus();
});
};
private _handleAfterShow = () => {
fireEvent(this, "after-show");
};
private _handleAfterHide = () => {
private _handleAfterHide(afterHideEvent: Event) {
afterHideEvent.stopPropagation();
this.open = false;
this._drawerOpen = false;
fireEvent(this, "closed");
};
private _handleHide = (ev: CustomEvent<{ source: Element }>) => {
if (
this.preventScrimClose &&
this._escapePressed &&
ev.detail.source === (ev.target as WaDrawer).drawer
) {
ev.preventDefault();
}
this._escapePressed = false;
};
private _handleKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
this._escapePressed = true;
}
};
private _handleCloseAction = (ev: Event) => {
const shouldClose = ev
.composedPath()
.some(
(node) =>
node instanceof HTMLElement &&
(node.getAttribute("data-dialog") === "close" ||
node.getAttribute("data-drawer") === "close")
);
if (shouldClose) {
this._drawerOpen = false;
}
};
const ev = new Event("closed", {
bubbles: true,
composed: true,
});
this.dispatchEvent(ev);
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
@@ -129,15 +51,7 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
id="drawer"
placement="bottom"
.open=${this._drawerOpen}
.lightDismiss=${!this.preventScrimClose}
.ariaLabelledby=${this.ariaLabelledBy}
.ariaDescribedby=${this.ariaDescribedBy}
@keydown=${this._handleKeyDown}
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-hide=${this._handleHide}
@wa-after-hide=${this._handleAfterHide}
@click=${this._handleCloseAction}
without-header
@touchstart=${this._handleTouchStart}
>
@@ -154,10 +68,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
}
private _handleTouchStart = (ev: TouchEvent) => {
if (this.preventScrimClose) {
return;
}
// Check if any element inside drawer in the composed path has scrollTop > 0
for (const path of ev.composedPath()) {
const el = path as HTMLElement;
@@ -318,24 +228,16 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
flex-direction: column;
min-height: 0;
}
.body {
padding: var(--ha-bottom-sheet-content-padding, 0);
box-sizing: border-box;
}
:host([flexcontent]) .body {
flex: 1;
max-width: 100%;
display: flex;
flex-direction: column;
padding: var(
--ha-bottom-sheet-content-padding,
var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
)
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
box-sizing: border-box;
}
slot[name="footer"] {
display: block;

View File

@@ -20,7 +20,7 @@ import {
mdiUndo,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { PropertyValues } from "lit";
import { css, html, ReactiveElement, render } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -28,7 +28,6 @@ import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import "./ha-code-editor-completion-items";
@@ -311,11 +310,6 @@ export class HaCodeEditor extends ReactiveElement {
});
this._canCopy = this._value?.length > 0;
const cmScroller = this.codemirror.dom.querySelector(".cm-scroller");
if (cmScroller) {
cmScroller.classList.add("ha-scrollbar");
}
// Update the toolbar. Creating it if required
this._updateToolbar();
}
@@ -808,105 +802,100 @@ export class HaCodeEditor extends ReactiveElement {
return [];
};
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
position: relative;
display: block;
--code-editor-toolbar-height: 28px;
}
static styles = css`
:host {
position: relative;
display: block;
--code-editor-toolbar-height: 28px;
}
:host(.error-state) .cm-gutters {
border-color: var(--error-state-color, var(--error-color)) !important;
}
:host(.error-state) .cm-gutters {
border-color: var(--error-state-color, var(--error-color)) !important;
}
:host(.hasToolbar) .cm-gutters {
padding-top: 0;
}
:host(.hasToolbar) .cm-gutters {
padding-top: 0;
}
:host(.hasToolbar) .cm-focused .cm-gutters {
padding-top: 1px;
}
:host(.hasToolbar) .cm-focused .cm-gutters {
padding-top: 1px;
}
:host(.error-state) .cm-content {
border-color: var(--error-state-color, var(--error-color)) !important;
}
:host(.error-state) .cm-content {
border-color: var(--error-state-color, var(--error-color)) !important;
}
:host(.hasToolbar) .cm-content {
border: none;
border-top: 1px solid var(--secondary-text-color);
}
:host(.hasToolbar) .cm-content {
border: none;
border-top: 1px solid var(--secondary-text-color);
}
:host(.hasToolbar) .cm-focused .cm-content {
border-top: 2px solid var(--primary-color);
padding-top: 15px;
}
:host(.hasToolbar) .cm-focused .cm-content {
border-top: 2px solid var(--primary-color);
padding-top: 15px;
}
:host(.fullscreen) {
position: fixed !important;
top: calc(var(--header-height, 56px) + 8px) !important;
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
z-index: 6;
border-radius: var(--ha-border-radius-lg) !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
background-color: var(
--code-editor-background-color,
var(--card-background-color)
) !important;
margin: 0 !important;
padding-top: var(--safe-area-inset-top) !important;
padding-left: var(--safe-area-inset-left) !important;
padding-right: var(--safe-area-inset-right) !important;
padding-bottom: var(--safe-area-inset-bottom) !important;
box-sizing: border-box !important;
display: block !important;
}
:host(.fullscreen) {
position: fixed !important;
top: calc(var(--header-height, 56px) + 8px) !important;
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
z-index: 6;
border-radius: var(--ha-border-radius-lg) !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
background-color: var(
--code-editor-background-color,
var(--card-background-color)
) !important;
margin: 0 !important;
padding-top: var(--safe-area-inset-top) !important;
padding-left: var(--safe-area-inset-left) !important;
padding-right: var(--safe-area-inset-right) !important;
padding-bottom: var(--safe-area-inset-bottom) !important;
box-sizing: border-box !important;
display: block !important;
}
:host(.hasToolbar) .cm-editor {
padding-top: var(--code-editor-toolbar-height);
}
:host(.hasToolbar) .cm-editor {
padding-top: var(--code-editor-toolbar-height);
}
:host(.fullscreen) .cm-editor {
height: 100% !important;
max-height: 100% !important;
border-radius: var(--ha-border-radius-square) !important;
}
:host(.fullscreen) .cm-editor {
height: 100% !important;
max-height: 100% !important;
border-radius: var(--ha-border-radius-square) !important;
}
:host(:not(.hasToolbar)) .code-editor-toolbar {
display: none !important;
}
:host(:not(.hasToolbar)) .code-editor-toolbar {
display: none !important;
}
.code-editor-toolbar {
--icon-button-toolbar-height: var(--code-editor-toolbar-height);
--icon-button-toolbar-color: var(
--code-editor-gutter-color,
var(--secondary-background-color, whitesmoke)
);
border-top-left-radius: var(--ha-border-radius-sm);
border-top-right-radius: var(--ha-border-radius-sm);
}
.code-editor-toolbar {
--icon-button-toolbar-height: var(--code-editor-toolbar-height);
--icon-button-toolbar-color: var(
--code-editor-gutter-color,
var(--secondary-background-color, whitesmoke)
);
border-top-left-radius: var(--ha-border-radius-sm);
border-top-right-radius: var(--ha-border-radius-sm);
}
.completion-info {
display: grid;
gap: 3px;
padding: 8px;
}
.completion-info {
display: grid;
gap: 3px;
padding: 8px;
}
/* Hide completion info on narrow screens */
@media (max-width: 600px) {
.cm-completionInfo,
.completion-info {
display: none;
}
}
`,
];
}
/* Hide completion info on narrow screens */
@media (max-width: 600px) {
.cm-completionInfo,
.completion-info {
display: none;
}
}
`;
}
declare global {

View File

@@ -6,7 +6,7 @@ import memoizeOne from "memoize-one";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
@@ -224,7 +224,7 @@ export class HaColorPicker extends LitElement {
`;
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
private _valueChanged(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const selected = ev.detail.value;
const normalized =

View File

@@ -1,31 +1,24 @@
import { SelectBase } from "@material/mwc-select/mwc-select-base";
import { mdiMenuDown } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../types";
import "./ha-attribute-icon";
import "./ha-dropdown";
import "./ha-dropdown-item";
import { ifDefined } from "lit/directives/if-defined";
import { debounce } from "../common/util/debounce";
import { nextRender } from "../common/util/render-status";
import "./ha-icon";
import type { HaIcon } from "./ha-icon";
import "./ha-ripple";
import "./ha-svg-icon";
export interface SelectOption {
label: string;
value: string;
iconPath?: string;
icon?: string;
attributeIcon?: {
stateObj: HassEntity;
attribute: string;
attributeValue?: string;
};
}
import type { HaSvgIcon } from "./ha-svg-icon";
import "./ha-menu";
@customElement("ha-control-select-menu")
export class HaControlSelectMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
export class HaControlSelectMenu extends SelectBase {
@query(".select") protected mdcRoot!: HTMLElement;
@query(".select-anchor") protected anchorElement!: HTMLDivElement | null;
@property({ type: Boolean, attribute: "show-arrow" })
public showArrow = false;
@@ -33,83 +26,95 @@ export class HaControlSelectMenu extends LitElement {
@property({ type: Boolean, attribute: "hide-label" })
public hideLabel = false;
@property({ type: Boolean })
public disabled = false;
@property() public options;
@property({ type: Boolean })
public required = false;
@property()
public label?: string;
@property()
public value?: string;
@property({ attribute: false }) public options: SelectOption[] = [];
@query("button") private _triggerButton!: HTMLButtonElement;
public override render() {
if (this.disabled) {
return this._renderTrigger();
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.get("options")) {
this.layoutOptions();
this.selectByValue(this.value);
}
return html`
<ha-dropdown placement="bottom" @wa-show=${this._showDropdown}>
${this._renderTrigger()} ${this.options.map(this._renderOption)}
</ha-dropdown>
`;
}
private _renderTrigger() {
const selectedText = this.getValueObject(this.options, this.value)?.label;
public override render() {
const classes = {
"select-disabled": this.disabled,
"select-required": this.required,
"select-no-value": !selectedText,
"select-invalid": !this.isUiValid,
"select-no-value": !this.selectedText,
};
return html`<button
?disabled=${this.disabled}
slot="trigger"
class="select-anchor ${classMap(classes)}"
>
${this._renderIcon()}
<div class="content">
${this.hideLabel
? nothing
: html`<p id="label" class="label">${this.label}</p>`}
${selectedText ? html`<p class="value">${selectedText}</p>` : nothing}
const labelledby = this.label && !this.hideLabel ? "label" : undefined;
const labelAttribute =
this.label && this.hideLabel ? this.label : undefined;
return html`
<div class="select ${classMap(classes)}">
<input
class="formElement"
.name=${this.name}
.value=${this.value}
hidden
?disabled=${this.disabled}
?required=${this.required}
/>
<!-- @ts-ignore -->
<div
class="select-anchor"
aria-autocomplete="none"
role="combobox"
aria-expanded=${this.menuOpen}
aria-invalid=${!this.isUiValid}
aria-haspopup="listbox"
aria-labelledby=${ifDefined(labelledby)}
aria-label=${ifDefined(labelAttribute)}
aria-required=${this.required}
aria-controls="listbox"
@focus=${this.onFocus}
@blur=${this.onBlur}
@click=${this.onClick}
@keydown=${this.onKeydown}
>
${this._renderIcon()}
<div class="content">
${this.hideLabel
? nothing
: html`<p id="label" class="label">${this.label}</p>`}
${this.selectedText
? html`<p class="value">${this.selectedText}</p>`
: nothing}
</div>
${this._renderArrow()}
<ha-ripple .disabled=${this.disabled}></ha-ripple>
</div>
${this.renderMenu()}
</div>
${this._renderArrow()}
</button>`;
`;
}
private _renderOption = (option: SelectOption) =>
html`<ha-dropdown-item
.value=${option.value}
.selected=${this.value === option.value}
>${option.iconPath
? html`<ha-svg-icon slot="icon" .path=${option.iconPath}></ha-svg-icon>`
: option.icon
? html`<ha-icon slot="icon" .icon=${option.icon}></ha-icon>`
: option.attributeIcon
? html`<ha-attribute-icon
slot="icon"
.hass=${this.hass}
.stateObj=${option.attributeIcon.stateObj}
.attribute=${option.attributeIcon.attribute}
.attributeValue=${option.attributeIcon.attributeValue}
></ha-attribute-icon>`
: nothing}
${option.label}</ha-dropdown-item
>`;
protected override renderMenu() {
const classes = this.getMenuClasses();
return html`<ha-menu
innerRole="listbox"
wrapFocus
class=${classMap(classes)}
activatable
.fullwidth=${this.fixedMenuPosition ? false : !this.naturalMenuWidth}
.open=${this.menuOpen}
.anchor=${this.anchorElement}
.fixed=${this.fixedMenuPosition}
@selected=${this.onSelected}
@opened=${this.onOpened}
@closed=${this.onClosed}
@items-updated=${this.onItemsUpdated}
@keydown=${this.handleTypeahead}
>
${this.renderMenuContent()}
</ha-menu>`;
}
private _renderArrow() {
if (!this.showArrow) {
return nothing;
}
if (!this.showArrow) return nothing;
return html`
<div class="icon">
@@ -119,42 +124,47 @@ export class HaControlSelectMenu extends LitElement {
}
private _renderIcon() {
const { iconPath, icon, attributeIcon } =
this.getValueObject(this.options, this.value) ?? {};
const index = this.mdcFoundation?.getSelectedIndex();
const items = this.menuElement?.items ?? [];
const item = index != null ? items[index] : undefined;
const defaultIcon = this.querySelector("[slot='icon']");
const icon = (item?.querySelector("[slot='graphic']") ?? null) as
| HaSvgIcon
| HaIcon
| null;
if (!defaultIcon && !icon) {
return null;
}
return html`
<div class="icon">
${iconPath
? html`<ha-svg-icon slot="icon" .path=${iconPath}></ha-svg-icon>`
: icon
? html`<ha-icon slot="icon" .icon=${icon}></ha-icon>`
: attributeIcon
? html`<ha-attribute-icon
slot="icon"
.hass=${this.hass}
.stateObj=${attributeIcon.stateObj}
.attribute=${attributeIcon.attribute}
.attributeValue=${attributeIcon.attributeValue}
></ha-attribute-icon>`
: defaultIcon
? html`<slot name="icon"></slot>`
: nothing}
${icon && icon.localName === "ha-svg-icon" && "path" in icon
? html`<ha-svg-icon .path=${icon.path}></ha-svg-icon>`
: icon && icon.localName === "ha-icon" && "icon" in icon
? html`<ha-icon .path=${icon.icon}></ha-icon>`
: html`<slot name="icon"></slot>`}
</div>
`;
}
private _showDropdown() {
this.style.setProperty(
"--control-select-menu-width",
`${this._triggerButton.offsetWidth}px`
connectedCallback() {
super.connectedCallback();
window.addEventListener("translations-updated", this._translationsUpdated);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
"translations-updated",
this._translationsUpdated
);
}
private getValueObject = memoizeOne(
(options: SelectOption[], value?: string) =>
options.find((option) => option.value === value)
);
private _translationsUpdated = debounce(async () => {
await nextRender();
this.layoutOptions();
}, 500);
static override styles = [
css`
@@ -176,8 +186,6 @@ export class HaControlSelectMenu extends LitElement {
-webkit-tap-highlight-color: transparent;
}
.select-anchor {
border: none;
text-align: left;
height: var(--control-select-menu-height);
padding: var(--control-select-menu-padding);
overflow: hidden;
@@ -203,12 +211,6 @@ export class HaControlSelectMenu extends LitElement {
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.25px;
}
.select-anchor:hover {
--control-select-menu-background-color: var(
--ha-color-on-neutral-quiet
);
}
.content {
display: flex;
flex-direction: column;
@@ -228,19 +230,16 @@ export class HaControlSelectMenu extends LitElement {
}
.label {
font-size: var(--ha-font-size-s);
font-size: 0.85em;
letter-spacing: 0.4px;
}
.select-no-value .label {
font-size: inherit;
line-height: inherit;
letter-spacing: inherit;
}
.content .value {
font-size: var(--ha-font-size-m);
}
.select-anchor:focus-visible {
box-shadow: 0 0 0 2px var(--control-select-menu-focus-color);
}
@@ -259,14 +258,10 @@ export class HaControlSelectMenu extends LitElement {
opacity: var(--control-select-menu-background-opacity);
}
.select-disabled.select-anchor {
.select-disabled .select-anchor {
cursor: not-allowed;
color: var(--disabled-color);
}
ha-dropdown::part(menu) {
min-width: var(--control-select-menu-width);
}
`,
];
}

View File

@@ -24,10 +24,10 @@ export class HaControlSwitch extends LitElement {
@property({ type: Boolean }) public checked = false;
// SVG icon path (if you need a non SVG icon instead, use the provided on icon slot to pass an <ha-icon slot="icon-on"> in)
@property({ attribute: false }) pathOn?: string;
@property({ attribute: false, type: String }) pathOn?: string;
// SVG icon path (if you need a non SVG icon instead, use the provided off icon slot to pass an <ha-icon slot="icon-off"> in)
@property({ attribute: false }) pathOff?: string;
@property({ attribute: false, type: String }) pathOff?: string;
@property({ type: String })
public label?: string;

View File

@@ -3,6 +3,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { debounce } from "../common/util/debounce";
import type { ConfigEntry, SubEntry } from "../data/config_entries";
import { getConfigEntry, getSubEntries } from "../data/config_entries";
@@ -13,8 +14,9 @@ import { fetchIntegrationManifest } from "../data/integration";
import { showOptionsFlowDialog } from "../dialogs/config-flow/show-dialog-options-flow";
import { showSubConfigFlowDialog } from "../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../types";
import "./ha-list-item";
import "./ha-select";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
import type { HaSelect } from "./ha-select";
const NONE = "__NONE_OPTION__";
@@ -71,35 +73,37 @@ export class HaConversationAgentPicker extends LitElement {
value = NONE;
}
const options: HaSelectOption[] = this._agents.map((agent) => ({
value: agent.id,
label: agent.name,
disabled:
agent.supported_languages !== "*" &&
agent.supported_languages.length === 0,
}));
if (!this.required) {
options.unshift({
value: NONE,
label: this.hass.localize(
"ui.components.conversation-agent-picker.none"
),
});
}
return html`
<ha-select
.label=${this.label ||
this.hass!.localize(
"ui.components.conversation-agent-picker.conversation_agent"
"ui.components.coversation-agent-picker.conversation_agent"
)}
.value=${value}
.required=${this.required}
.disabled=${this.disabled}
@selected=${this._changed}
.options=${options}
></ha-select
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
>
${!this.required
? html`<ha-list-item .value=${NONE}>
${this.hass!.localize(
"ui.components.coversation-agent-picker.none"
)}
</ha-list-item>`
: nothing}
${this._agents.map(
(agent) =>
html`<ha-list-item
.value=${agent.id}
.disabled=${agent.supported_languages !== "*" &&
agent.supported_languages.length === 0}
>
${agent.name}
</ha-list-item>`
)}</ha-select
>${(this._subConfigEntry &&
this._configEntry?.supported_subentry_types[
this._subConfigEntry.subentry_type
@@ -234,17 +238,17 @@ export class HaConversationAgentPicker extends LitElement {
}
`;
private _changed(ev: HaSelectSelectEvent): void {
const value = ev.detail.value;
private _changed(ev): void {
const target = ev.target as HaSelect;
if (
!this.hass ||
value === "" ||
value === this.value ||
(this.value === undefined && value === NONE)
target.value === "" ||
target.value === this.value ||
(this.value === undefined && target.value === NONE)
) {
return;
}
this.value = value === NONE ? undefined : value;
this.value = target.value === NONE ? undefined : target.value;
fireEvent(this, "value-changed", { value: this.value });
fireEvent(this, "supported-languages-changed", {
value: this._agents!.find((agent) => agent.id === this.value)

View File

@@ -65,10 +65,6 @@ export class HaDateRangePicker extends LitElement {
@property({ attribute: "time-picker", type: Boolean })
public timePicker = false;
public open(): void {
this._openPicker();
}
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public minimal = false;
@@ -310,15 +306,6 @@ export class HaDateRangePicker extends LitElement {
return dateRangePicker.vueComponent.$children[0];
}
private _openPicker() {
if (!this._dateRangePicker.open) {
const datePicker = this.shadowRoot!.querySelector(
"date-range-picker div.date-range-inputs"
) as any;
datePicker?.click();
}
}
private _handleInputClick() {
// close the date picker, so it will open again on the click event
if (this._dateRangePicker.open) {

View File

@@ -7,9 +7,8 @@ import { nextRender } from "../common/util/render-status";
import { haStyleDialog } from "../resources/styles";
import type { HomeAssistant } from "../types";
import type { DatePickerDialogParams } from "./ha-date-input";
import "./ha-button";
import "./ha-dialog-footer";
import "./ha-dialog";
import "./ha-button";
@customElement("ha-dialog-date-picker")
export class HaDialogDatePicker extends LitElement {
@@ -23,8 +22,6 @@ export class HaDialogDatePicker extends LitElement {
@state() private _params?: DatePickerDialogParams;
@state() private _open = false;
@state() private _value?: string;
public async showDialog(params: DatePickerDialogParams): Promise<void> {
@@ -33,14 +30,9 @@ export class HaDialogDatePicker extends LitElement {
await nextRender();
this._params = params;
this._value = params.value;
this._open = true;
}
public closeDialog() {
this._open = false;
}
private _dialogClosed() {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -49,13 +41,7 @@ export class HaDialogDatePicker extends LitElement {
if (!this._params) {
return nothing;
}
return html`<ha-dialog
.hass=${this.hass}
.open=${this._open}
width="small"
without-header
@closed=${this._dialogClosed}
>
return html`<ha-dialog open @closed=${this.closeDialog}>
<app-datepicker
.value=${this._value}
.min=${this._params.min}
@@ -64,39 +50,34 @@ export class HaDialogDatePicker extends LitElement {
@datepicker-value-updated=${this._valueChanged}
.firstDayOfWeek=${this._params.firstWeekday}
></app-datepicker>
<div class="bottom-actions">
${this._params.canClear
? html`<ha-button
slot="secondaryAction"
@click=${this._clear}
variant="danger"
appearance="plain"
>
${this.hass.localize("ui.dialogs.date-picker.clear")}
</ha-button>`
: nothing}
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this._setToday}
>
${this.hass.localize("ui.dialogs.date-picker.today")}
</ha-button>
</div>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._setValue}>
${this.hass.localize("ui.common.ok")}
</ha-button>
</ha-dialog-footer>
${this._params.canClear
? html`<ha-button
slot="secondaryAction"
@click=${this._clear}
variant="danger"
appearance="plain"
>
${this.hass.localize("ui.dialogs.date-picker.clear")}
</ha-button>`
: nothing}
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this._setToday}
>
${this.hass.localize("ui.dialogs.date-picker.today")}
</ha-button>
<ha-button
appearance="plain"
slot="primaryAction"
dialogaction="cancel"
class="cancel-btn"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._setValue}>
${this.hass.localize("ui.common.ok")}
</ha-button>
</ha-dialog>`;
}
@@ -129,18 +110,9 @@ export class HaDialogDatePicker extends LitElement {
css`
ha-dialog {
--dialog-content-padding: 0;
}
.bottom-actions {
display: flex;
gap: var(--ha-space-4);
justify-content: center;
align-items: center;
width: 100%;
margin-bottom: var(--ha-space-1);
--justify-action-buttons: space-between;
}
app-datepicker {
display: block;
margin-inline: auto;
--app-datepicker-accent-color: var(--primary-color);
--app-datepicker-bg-color: transparent;
--app-datepicker-color: var(--primary-text-color);
@@ -157,6 +129,11 @@ export class HaDialogDatePicker extends LitElement {
app-datepicker::part(body) {
direction: ltr;
}
@media all and (min-width: 450px) {
ha-dialog {
--mdc-dialog-min-width: 300px;
}
}
@media all and (max-width: 450px), all and (max-height: 500px) {
app-datepicker {
width: 100%;

View File

@@ -9,7 +9,7 @@ import { customElement } from "lit/decorators";
*
* @summary
* A simple footer container for dialog actions,
* typically used as the `footer` slot in `ha-dialog`.
* typically used as the `footer` slot in `ha-wa-dialog`.
*
* @slot primaryAction - Primary action button(s), aligned to the end.
* @slot secondaryAction - Secondary action button(s), placed before the primary action.

View File

@@ -1,447 +1,193 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import type WaDialog from "@home-assistant/webawesome/dist/components/dialog/dialog";
import { DialogBase } from "@material/mwc-dialog/mwc-dialog-base";
import { styles } from "@material/mwc-dialog/mwc-dialog.css";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
import "./ha-dialog-header";
import "./ha-icon-button";
export type DialogWidth = "small" | "medium" | "large" | "full";
const SUPPRESS_DEFAULT_PRESS_SELECTOR = ["button", "ha-list-item"];
export const createCloseHeading = (
hass: HomeAssistant | undefined,
title: string | TemplateResult
) => html`
<div class="header_title">
<ha-icon-button
.label=${hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
dialogAction="close"
class="header_button"
></ha-icon-button>
<span>${title}</span>
</div>
`;
/**
* Home Assistant dialog component
*
* @element ha-dialog
* @extends {LitElement}
*
* @summary
* A stylable dialog built using the `wa-dialog` component, providing a standardized header (ha-dialog-header),
* body, and footer (preferably using `ha-dialog-footer`) with slots
*
* You can open and close the dialog declaratively by using the `data-dialog="close"` attribute.
* @see https://webawesome.com/docs/components/dialog/#opening-and-closing-dialogs-declaratively
*
* @slot header - Replace the entire header area.
* @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerTitle - Custom title content (used when header-title is not set).
* @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set).
* @slot headerActionItems - Trailing header actions (e.g. buttons, menus).
* @slot - Dialog content body.
* @slot footer - Dialog footer content.
*
* @csspart dialog - The dialog surface.
* @csspart header - The header container.
* @csspart body - The scrollable body container.
* @csspart footer - The footer container.
*
* @cssprop --dialog-content-padding - Padding for the dialog content sections.
* @cssprop --ha-dialog-show-duration - Show animation duration.
* @cssprop --ha-dialog-hide-duration - Hide animation duration.
* @cssprop --ha-dialog-surface-background - Dialog background color.
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface.
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
*
* @attr {boolean} open - Controls the dialog open state.
* @attr {("alert"|"standard")} type - Dialog type. Defaults to "standard".
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium".
* @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false.
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts.
*
* @event opened - Fired when the dialog is shown.
* @event closed - Fired after the dialog is hidden.
*
* @remarks
* **Focus Management:**
* To automatically focus an element when the dialog opens, add the `autofocus` attribute to it.
* Components with `delegatesFocus: true` (like `ha-form`) will forward focus to their first focusable child.
* Example: `<ha-form autofocus .schema=${schema}></ha-form>`
*
* @see https://github.com/home-assistant/frontend/issues/27143
*/
@customElement("ha-dialog")
export class HaDialog extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
export class HaDialog extends DialogBase {
protected readonly [FOCUS_TARGET];
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@property({ attribute: "aria-describedby" })
public ariaDescribedBy?: string;
@property({ type: Boolean, reflect: true })
public open = false;
@property({ reflect: true })
public type: "alert" | "standard" = "standard";
@property({ type: String, reflect: true, attribute: "width" })
public width: DialogWidth = "medium";
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false;
@property({ attribute: "header-title" })
public headerTitle?: string;
@property({ attribute: "header-subtitle" })
public headerSubtitle?: string;
@property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below";
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@property({ type: Boolean, attribute: "without-header" })
public withoutHeader = false;
@state()
private _open = false;
@query(".body") public bodyContainer!: HTMLDivElement;
@state()
private _bodyScrolled = false;
private _escapePressed = false;
protected get scrollableElement(): HTMLElement | null {
return this.bodyContainer;
public scrollToPos(x: number, y: number) {
this.contentElement?.scrollTo(x, y);
}
protected updated(
changedProperties: Map<string | number | symbol, unknown>
): void {
super.updated(changedProperties);
if (changedProperties.has("open")) {
this._open = this.open;
}
protected renderHeading() {
return html`<slot name="heading"> ${super.renderHeading()} </slot>`;
}
protected render() {
return html`
<wa-dialog
.open=${this._open}
.lightDismiss=${!this.preventScrimClose}
without-header
aria-labelledby=${ifDefined(
this.ariaLabelledBy ||
(this.headerTitle !== undefined ? "ha-dialog-title" : undefined)
)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@keydown=${this._handleKeyDown}
@wa-hide=${this._handleHide}
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
>
${!this.withoutHeader
? html` <slot name="header">
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
.showBorder=${this._bodyScrolled}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle !== undefined
? html`<span slot="title" class="title" id="ha-dialog-title">
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>`
: nothing}
<div class="content-wrapper">
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<slot></slot>
</div>
${this.renderScrollableFades()}
</div>
<slot name="footer" slot="footer"></slot>
</wa-dialog>
`;
}
private _handleShow = async () => {
this._open = true;
fireEvent(this, "opened");
await this.updateComplete;
requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass)) {
const element = this.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-dialog-autofocus";
}
this.hass?.auth.external?.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
protected firstUpdated(): void {
super.firstUpdated();
this.suppressDefaultPressSelector = [
this.suppressDefaultPressSelector,
SUPPRESS_DEFAULT_PRESS_SELECTOR,
].join(", ");
this._updateScrolledAttribute();
this.contentElement?.addEventListener("scroll", this._onScroll, {
passive: true,
});
};
}
private _handleAfterShow = () => {
fireEvent(this, "after-show");
};
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
if (ev.eventPhase === Event.AT_TARGET) {
this._open = false;
fireEvent(this, "closed");
}
};
public disconnectedCallback(): void {
disconnectedCallback(): void {
super.disconnectedCallback();
this._open = false;
this.contentElement.removeEventListener("scroll", this._onScroll);
}
@eventOptions({ passive: true })
private _handleBodyScroll(ev: Event) {
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
private _onScroll = () => {
this._updateScrolledAttribute();
};
private _updateScrolledAttribute() {
if (!this.contentElement) return;
this.toggleAttribute("scrolled", this.contentElement.scrollTop !== 0);
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
this._escapePressed = true;
}
}
static override styles = [
styles,
css`
:host([scrolled]) ::slotted(ha-dialog-header) {
border-bottom: 1px solid
var(--mdc-dialog-scroll-divider-color, rgba(0, 0, 0, 0.12));
}
.mdc-dialog {
--mdc-dialog-scroll-divider-color: var(
--dialog-scroll-divider-color,
var(--divider-color)
);
z-index: var(--dialog-z-index, 8);
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
--mdc-typography-headline6-font-size: 1.574rem;
}
.mdc-dialog::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
-webkit-backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
}
.mdc-dialog .mdc-dialog__scrim {
background-color: var(--mdc-dialog-scrim-color, none);
}
.mdc-dialog__actions {
justify-content: var(--justify-action-buttons, flex-end);
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
}
.mdc-dialog__actions span:nth-child(1) {
flex: var(--secondary-action-button-flex, unset);
}
.mdc-dialog__actions span:nth-child(2) {
flex: var(--primary-action-button-flex, unset);
}
.mdc-dialog__container {
align-items: var(--vertical-align-dialog, center);
padding: var(--dialog-container-padding, 0);
}
.mdc-dialog__title {
padding: var(--ha-space-4) var(--ha-space-4) 0 var(--ha-space-4);
}
.mdc-dialog__title:has(span) {
padding: var(--ha-space-3) var(--ha-space-3) 0;
}
.mdc-dialog__title::before {
content: unset;
}
.mdc-dialog .mdc-dialog__content {
position: var(--dialog-content-position, relative);
padding: var(--dialog-content-padding, var(--ha-space-6));
}
:host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: var(--dialog-content-padding, var(--ha-space-6));
}
.mdc-dialog .mdc-dialog__surface {
position: var(--dialog-surface-position, relative);
top: var(--dialog-surface-top);
margin-top: var(--dialog-surface-margin-top);
min-width: var(--mdc-dialog-min-width, auto);
min-height: var(--mdc-dialog-min-height, auto);
border-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-3xl)
);
-webkit-backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
background: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
padding: var(--dialog-surface-padding, 0);
}
:host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex;
flex-direction: column;
}
private _handleHide(ev: CustomEvent<{ source: Element }>) {
if (
this.preventScrimClose &&
this._escapePressed &&
ev.detail.source === (ev.target as WaDialog).dialog
) {
ev.preventDefault();
}
this._escapePressed = false;
}
static get styles() {
return [
...super.styles,
haStyleScrollbar,
css`
wa-dialog {
--full-width: var(
--ha-dialog-width-full,
min(95vw, var(--safe-width))
);
--width: min(var(--ha-dialog-width-md, 580px), var(--full-width));
--spacing: var(--dialog-content-padding, var(--ha-space-6));
--show-duration: var(--ha-dialog-show-duration, 200ms);
--hide-duration: var(--ha-dialog-hide-duration, 200ms);
--ha-dialog-surface-background: var(
--card-background-color,
var(--ha-color-surface-default)
);
--wa-color-surface-raised: var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
);
--wa-panel-border-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-3xl)
);
max-width: var(--ha-dialog-max-width, var(--safe-width));
}
@media (prefers-reduced-motion: reduce) {
wa-dialog {
--show-duration: 1ms;
--hide-duration: 1ms;
}
}
:host([width="small"]) wa-dialog {
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
}
:host([width="large"]) wa-dialog {
--width: min(var(--ha-dialog-width-lg, 1024px), var(--full-width));
}
:host([width="full"]) wa-dialog {
--width: var(--full-width);
}
wa-dialog::part(dialog) {
color: var(--primary-text-color);
min-width: var(--width, var(--full-width));
max-width: var(--width, var(--full-width));
max-height: var(
--ha-dialog-max-height,
calc(var(--safe-height) - var(--ha-space-20))
);
min-height: var(--ha-dialog-min-height);
margin-top: var(--dialog-surface-margin-top, auto);
/* Used to offset the dialog from the safe areas when space is limited */
transform: translate(
calc(
var(--safe-area-offset-left, 0px) - var(
--safe-area-offset-right,
0px
)
),
calc(
var(--safe-area-offset-top, 0px) - var(
--safe-area-offset-bottom,
0px
)
)
);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
:host([type="standard"]) {
--ha-dialog-border-radius: 0;
wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
}
}
}
.header-title-container {
display: flex;
align-items: center;
}
.header-title {
margin: 0;
margin-bottom: 0;
color: var(--ha-dialog-header-title-color, var(--primary-text-color));
font-size: var(
--ha-dialog-header-title-font-size,
var(--ha-font-size-2xl)
);
line-height: var(
--ha-dialog-header-title-line-height,
var(--ha-line-height-condensed)
);
font-weight: var(
--ha-dialog-header-title-font-weight,
var(--ha-font-weight-normal)
);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--ha-space-3);
}
wa-dialog::part(body) {
padding: 0;
display: flex;
flex-direction: column;
max-width: 100%;
overflow: hidden;
}
.content-wrapper {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.body {
position: var(--dialog-content-position, relative);
padding: var(
--dialog-content-padding,
0 var(--ha-space-6) var(--ha-space-6) var(--ha-space-6)
);
overflow: auto;
flex-grow: 1;
}
:host([flexcontent]) .body {
max-width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
wa-dialog::part(footer) {
padding: 0;
}
::slotted([slot="footer"]) {
display: flex;
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
gap: var(--ha-space-3);
justify-content: flex-end;
align-items: center;
width: 100%;
}
`,
];
}
.header_title {
display: flex;
align-items: center;
direction: var(--direction);
}
.header_title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
padding-left: var(--ha-space-1);
padding-right: var(--ha-space-1);
margin-right: var(--ha-space-3);
margin-inline-end: var(--ha-space-3);
margin-inline-start: initial;
}
.header_button {
text-decoration: none;
color: inherit;
inset-inline-start: initial;
inset-inline-end: calc(var(--ha-space-3) * -1);
direction: var(--direction);
}
.dialog-actions {
inset-inline-start: initial !important;
inset-inline-end: 0 !important;
direction: var(--direction);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog": HaDialog;
}
interface HASSDomEvents {
opened: undefined;
"after-show": undefined;
closed: undefined;
}
}

View File

@@ -0,0 +1,50 @@
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { customElement, property } from "lit/decorators";
@customElement("ha-divider")
export class HaMdDivider extends LitElement {
@property() public label?: string;
public render() {
return html`
<div
role=${ifDefined(this.label ? "separator" : undefined)}
aria-label=${ifDefined(this.label)}
>
<span class="line"></span>
${this.label
? html`
<span class="label">${this.label}</span>
<span class="line"></span>
`
: nothing}
</div>
`;
}
static styles = css`
:host {
width: var(--ha-divider-width, 100%);
}
div {
display: flex;
align-items: center;
justify-content: center;
}
.label {
padding: var(--ha-divider-label-padding, 0 16px);
}
.line {
flex: 1;
background-color: var(--divider-color);
height: var(--ha-divider-line-height, 1px);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-divider": HaMdDivider;
}
}

View File

@@ -4,18 +4,6 @@ import type { PropertyValues } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { HASSDomEvent } from "../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"hass-layout-transition": { active: boolean; reason?: string };
}
interface HTMLElementEventMap {
"hass-layout-transition": HASSDomEvent<
HASSDomEvents["hass-layout-transition"]
>;
}
}
const blockingElements = (document as any).$blockingElements;
@@ -27,30 +15,6 @@ export class HaDrawer extends DrawerBase {
private _rtlStyle?: HTMLElement;
private _sidebarTransitionActive = false;
private _handleDrawerTransitionStart = (ev: TransitionEvent) => {
if (ev.propertyName !== "width" || this._sidebarTransitionActive) {
return;
}
this._sidebarTransitionActive = true;
fireEvent(window, "hass-layout-transition", {
active: true,
reason: "sidebar",
});
};
private _handleDrawerTransitionEnd = (ev: TransitionEvent) => {
if (ev.propertyName !== "width" || !this._sidebarTransitionActive) {
return;
}
this._sidebarTransitionActive = false;
fireEvent(window, "hass-layout-transition", {
active: false,
reason: "sidebar",
});
};
protected createAdapter() {
return {
...super.createAdapter(),
@@ -99,38 +63,6 @@ export class HaDrawer extends DrawerBase {
}
}
protected firstUpdated() {
super.firstUpdated();
this.mdcRoot?.addEventListener(
"transitionstart",
this._handleDrawerTransitionStart
);
this.mdcRoot?.addEventListener(
"transitionend",
this._handleDrawerTransitionEnd
);
this.mdcRoot?.addEventListener(
"transitioncancel",
this._handleDrawerTransitionEnd
);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.mdcRoot?.removeEventListener(
"transitionstart",
this._handleDrawerTransitionStart
);
this.mdcRoot?.removeEventListener(
"transitionend",
this._handleDrawerTransitionEnd
);
this.mdcRoot?.removeEventListener(
"transitioncancel",
this._handleDrawerTransitionEnd
);
}
private async _setupSwipe() {
const hammer = await import("../resources/hammer");
this._mc = new hammer.Manager(document, {
@@ -158,16 +90,6 @@ export class HaDrawer extends DrawerBase {
border-color: var(--divider-color, rgba(0, 0, 0, 0.12));
inset-inline-start: 0 !important;
inset-inline-end: initial !important;
transition-property: transform, width;
transition-duration:
var(--mdc-drawer-transition-duration, 0.2s),
var(--ha-animation-duration-normal);
transition-timing-function:
var(
--mdc-drawer-transition-timing-function,
cubic-bezier(0.4, 0, 0.2, 1)
),
ease;
}
.mdc-drawer.mdc-drawer--modal.mdc-drawer--open {
z-index: 200;
@@ -181,15 +103,6 @@ export class HaDrawer extends DrawerBase {
direction: var(--direction);
width: 100%;
box-sizing: border-box;
transition:
padding-left var(--ha-animation-duration-normal) ease,
padding-inline-start var(--ha-animation-duration-normal) ease;
}
@media (prefers-reduced-motion: reduce) {
.mdc-drawer,
.mdc-drawer-app-content {
transition: none;
}
}
`,
];

View File

@@ -1,9 +1,9 @@
import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item";
import "@home-assistant/webawesome/dist/components/icon/icon";
import { mdiCheckboxBlankOutline, mdiCheckboxMarked } from "@mdi/js";
import { css, type CSSResultGroup, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement } from "lit/decorators";
import "./ha-svg-icon";
import { mdiCheckboxBlankOutline, mdiCheckboxMarked } from "@mdi/js";
/**
* Home Assistant dropdown item component
@@ -17,8 +17,6 @@ import "./ha-svg-icon";
*/
@customElement("ha-dropdown-item")
export class HaDropdownItem extends DropdownItem {
@property({ type: Boolean, reflect: true }) selected = false;
protected renderCheckboxIcon() {
return html`
<ha-svg-icon
@@ -49,16 +47,6 @@ export class HaDropdownItem extends DropdownItem {
:host([variant="danger"]) #icon ::slotted(*) {
color: var(--ha-color-on-danger-quiet);
}
:host([selected]) {
font-weight: var(--ha-font-weight-medium);
color: var(--primary-color);
background-color: var(--ha-color-fill-primary-quiet-resting);
--icon-primary-color: var(--primary-color);
}
:host([selected]:hover) {
background-color: var(--ha-color-fill-primary-quiet-hover);
}
`,
];
}

View File

@@ -1,16 +1,6 @@
import type WaButton from "@home-assistant/webawesome/dist/components/button/button";
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaDropdownItem } from "./ha-dropdown-item";
/**
* Event type for the ha-dropdown component when an item is selected.
* @param T - The type of the value of the selected item.
*/
export type HaDropdownSelectEvent<T = string> = CustomEvent<{
item: Omit<HaDropdownItem, "value"> & { value: T };
}>;
/**
* Home Assistant dropdown component
@@ -23,37 +13,11 @@ export type HaDropdownSelectEvent<T = string> = CustomEvent<{
*
*/
@customElement("ha-dropdown")
// @ts-ignore Allow to set an alternative anchor element
export class HaDropdown extends Dropdown {
@property({ attribute: false }) dropdownTag = "ha-dropdown";
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
public get anchorElement(): HTMLButtonElement | WaButton | undefined {
// @ts-ignore Allow to set an anchor element on popup
return this.popup?.anchor as HTMLButtonElement | WaButton | undefined;
}
public set anchorElement(element: HTMLButtonElement | WaButton | undefined) {
// @ts-ignore Allow to get the current anchor element from popup
if (!this.popup) {
return;
}
// @ts-ignore Allow to get the current anchor element from popup
this.popup.anchor = element;
}
/** Get the slotted trigger button, a <wa-button> or <button> element */
// @ts-ignore Override parent method to be able to use alternative anchor
// eslint-disable-next-line @typescript-eslint/naming-convention
private override getTrigger(): HTMLButtonElement | WaButton | null {
if (this.anchorElement) {
return this.anchorElement;
}
// @ts-ignore fallback to default trigger slot if no anchorElement is set
return super.getTrigger();
}
static get styles(): CSSResultGroup {
return [
Dropdown.styles,

View File

@@ -6,7 +6,6 @@ import { fireEvent } from "../common/dom/fire_event";
import "./ha-base-time-input";
import type { TimeChangedEvent } from "./ha-base-time-input";
import "./ha-button-toggle-group";
import type { ValueChangedEvent } from "../types";
export interface HaDurationData {
days?: number;
@@ -153,9 +152,7 @@ class HaDurationInput extends LitElement {
: NaN;
}
private _durationChanged(
ev: ValueChangedEvent<TimeChangedEvent | undefined>
) {
private _durationChanged(ev: CustomEvent<{ value?: TimeChangedEvent }>) {
ev.stopPropagation();
const value = ev.detail.value ? { ...ev.detail.value } : undefined;

View File

@@ -39,7 +39,7 @@ export class HaFab extends FabBase {
}
:disabled {
--mdc-theme-secondary: var(--disabled-text-color);
cursor: not-allowed !important;
pointer-events: none;
}
`,
// safari workaround - must be explicit

View File

@@ -31,7 +31,6 @@ import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list";
import "./ha-list-item";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
@customElement("ha-filter-categories")
export class HaFilterCategories extends SubscribeMixin(LitElement) {
@@ -175,7 +174,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
}
}
private _handleAction(ev: HaDropdownSelectEvent) {
private _handleAction(ev: CustomEvent<{ item: { value: string } }>) {
const categoryId = (ev.currentTarget as any).categoryId;
const action = ev.detail.item.value;
switch (action) {
@@ -315,13 +314,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
}
ha-list {
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: var(--ha-space-1);
--mdc-list-side-padding-left: var(--ha-space-4);
--mdc-list-side-padding-right: 4px;
--mdc-icon-button-size: 36px;
}
ha-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
ha-dropdown-item {
font-size: var(--ha-font-size-m);
}

View File

@@ -122,10 +122,7 @@ export class HaFilterDevices extends LitElement {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-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
`${this.clientHeight - 49 - 32}px`; // 32px is the height of the search input
}, 300);
}
}

View File

@@ -110,10 +110,7 @@ export class HaFilterDomains extends LitElement {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-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
`${this.clientHeight - 49 - 32}px`; // 32px is the height of the search input
}, 300);
}
}
@@ -182,9 +179,6 @@ export class HaFilterDomains extends LitElement {
margin-inline-start: initial;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;

View File

@@ -101,10 +101,7 @@ export class HaFilterEntities extends LitElement {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-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
`${this.clientHeight - 49 - 32}px`; // 32px is the height of the search input
}, 300);
}
}

View File

@@ -99,10 +99,7 @@ export class HaFilterIntegrations extends LitElement {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-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
`${this.clientHeight - 49 - 32}px`; // 32px is the height of the search input
}, 300);
}
}
@@ -202,9 +199,6 @@ export class HaFilterIntegrations extends LitElement {
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;

View File

@@ -145,11 +145,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - (49 + 48 + 32 + 4)}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
// 48px - height of ha-list-item
`${this.clientHeight - (49 + 48 + 32)}px`;
}, 300);
}
}

View File

@@ -164,9 +164,6 @@ export class HaFilterVoiceAssistants extends LitElement {
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;

View File

@@ -359,7 +359,7 @@ export class HaFloorPicker extends LitElement {
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.floor-picker.add_new_suggestion",
"ui.components.floor-picker.add_new_sugestion",
{
name: searchString,
}

View File

@@ -76,11 +76,6 @@ export class HaFormFloat extends LitElement implements HaFormElement {
return;
}
// Allow user to start typing a negative zero
if (rawValue === "-0") {
return;
}
if (rawValue !== "") {
value = parseFloat(rawValue);
if (isNaN(value)) {

View File

@@ -11,7 +11,8 @@ import "../ha-formfield";
import "../ha-icon-button";
import "../ha-picker-field";
import type { HaDropdown, HaDropdownSelectEvent } from "../ha-dropdown";
import type { HaDropdown } from "../ha-dropdown";
import type { HaDropdownItem } from "../ha-dropdown-item";
import type {
HaFormElement,
HaFormMultiSelectData,
@@ -107,7 +108,7 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
`;
}
protected _toggleItem(ev: HaDropdownSelectEvent) {
protected _toggleItem(ev: CustomEvent<{ item: HaDropdownItem }>) {
ev.preventDefault(); // keep the dropdown open
const value = ev.detail.item.value;
const action = (ev.detail.item as any).action;

View File

@@ -17,7 +17,6 @@ import type {
HaFormOptionalActionsSchema,
HaFormSchema,
} from "./types";
import type { HaDropdownSelectEvent } from "../ha-dropdown";
const NO_ACTIONS = [];
@@ -143,7 +142,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
`;
}
private _handleAddAction(ev: HaDropdownSelectEvent) {
private _handleAddAction(ev: CustomEvent<{ item: { value: string } }>) {
const action = ev.detail.item.value;
this._displayActions = [...(this._displayActions ?? []), action];
}

View File

@@ -53,7 +53,7 @@ export class HaFormfield extends FormfieldBase {
:host(:not([alignEnd])) ::slotted(ha-switch) {
margin-right: 10px;
margin-inline-end: 10px;
margin-inline-start: initial;
margin-inline-start: inline;
}
.mdc-form-field {
align-items: var(--ha-formfield-align-items, center);

View File

@@ -30,7 +30,7 @@ export class HaGauge extends LitElement {
@property({ attribute: false })
public formatOptions?: Intl.NumberFormatOptions;
@property({ attribute: false }) public valueText?: string;
@property({ attribute: false, type: String }) public valueText?: string;
@property({ attribute: false }) public locale!: FrontendLocaleData;

View File

@@ -48,7 +48,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
section?: string
) => (PickerComboBoxItem | string)[] | undefined;
@property({ attribute: false })
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
@@ -434,10 +434,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
width: max(var(--body-width), 250px);
max-width: var(

View File

@@ -1,4 +1,4 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { mdiHelpCircle } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@@ -25,7 +25,7 @@ export class HaHelpTooltip extends LitElement {
protected render(): TemplateResult {
return html`
<ha-svg-icon id="svg-icon" .path=${mdiHelpCircleOutline}></ha-svg-icon>
<ha-svg-icon id="svg-icon" .path=${mdiHelpCircle}></ha-svg-icon>
<ha-tooltip for="svg-icon" .placement=${this.position}>
${this.label}
</ha-tooltip>

View File

@@ -113,13 +113,13 @@ class HaHsColorPicker extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
@property({ attribute: false })
@property({ type: Number, attribute: false })
public renderSize?: number;
@property({ type: Array })
public value?: [number, number];
@property({ attribute: false })
@property({ attribute: false, type: Number })
public colorBrightness?: number;
@property({ type: Number })
@@ -131,10 +131,10 @@ class HaHsColorPicker extends LitElement {
@property({ type: Number })
public ww?: number;
@property({ attribute: false })
@property({ attribute: false, type: Number })
public minKelvin?: number;
@property({ attribute: false })
@property({ attribute: false, type: Number })
public maxKelvin?: number;
@query("#canvas") private _canvas!: HTMLCanvasElement;

View File

@@ -19,7 +19,7 @@ export interface HaIconButtonToolbarItem {
@customElement("ha-icon-button-toolbar")
export class HaIconButtonToolbar extends LitElement {
@property({ attribute: false })
@property({ type: Array, attribute: false })
public items: (HaIconButtonToolbarItem | string)[] = [];
@queryAll("ha-icon-button") private _buttons?: HaIconButton[];

View File

@@ -9,6 +9,7 @@ import type { HomeAssistant } from "../types";
import "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-icon-button";
import "./ha-md-divider";
import "./ha-svg-icon";
import "./ha-tooltip";

View File

@@ -182,7 +182,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.label-picker.add_new_suggestion",
"ui.components.label-picker.add_new_sugestion",
{
name: searchString,
}

View File

@@ -107,6 +107,9 @@ export class HaLanguagePicker extends LitElement {
@property({ attribute: "no-sort", type: Boolean }) public noSort = false;
@property({ attribute: "inline-arrow", type: Boolean })
public inlineArrow = false;
@state() _defaultLanguages: string[] = [];
@query("ha-generic-picker", true) public genericPicker!: HaGenericPicker;

View File

@@ -134,50 +134,15 @@ export class HaMarkdown extends LitElement {
}
table[role="presentation"] {
--markdown-table-border-collapse: separate;
--markdown-table-border-width: 0;
--markdown-table-border-width: attr(border, 0);
--markdown-table-padding-inline: 0;
--markdown-table-padding-block: 0;
th,
td {
vertical-align: middle;
}
}
table[role="presentation"] td[valign="top"],
table[role="presentation"] th[valign="top"] {
vertical-align: top;
}
table[role="presentation"] td[valign="middle"],
table[role="presentation"] th[valign="middle"] {
vertical-align: middle;
}
table[role="presentation"] td[valign="bottom"],
table[role="presentation"] th[valign="bottom"] {
vertical-align: bottom;
}
table[role="presentation"] td[valign="baseline"],
table[role="presentation"] th[valign="baseline"] {
vertical-align: baseline;
}
@supports (border-width: attr(border px, 0)) {
table[role="presentation"] {
--markdown-table-border-width: attr(border px, 0);
}
table[role="presentation"] th,
table[role="presentation"] td {
th {
vertical-align: attr(valign, middle);
}
td {
vertical-align: attr(valign, middle);
}
}
table[role="presentation"][border="0"] {
--markdown-table-border-width: 0;
}
table[role="presentation"][border="1"] {
--markdown-table-border-width: 1px;
}
table[role="presentation"][border="2"] {
--markdown-table-border-width: 2px;
}
table[role="presentation"][border="3"] {
--markdown-table-border-width: 3px;
}
table {
border-collapse: var(--markdown-table-border-collapse, collapse);

View File

@@ -0,0 +1,263 @@
import { Dialog } from "@material/web/dialog/internal/dialog";
import { styles } from "@material/web/dialog/internal/dialog-styles";
import {
type DialogAnimation,
DIALOG_DEFAULT_CLOSE_ANIMATION,
DIALOG_DEFAULT_OPEN_ANIMATION,
} from "@material/web/dialog/internal/animations";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
// workaround to be able to overlay a dialog with another dialog
Dialog.addInitializer(async (instance) => {
await instance.updateComplete;
const dialogInstance = instance as HaMdDialog;
// @ts-expect-error dialog is private
dialogInstance.dialog.prepend(dialogInstance.scrim);
// @ts-expect-error scrim is private
dialogInstance.scrim.style.inset = 0;
// @ts-expect-error scrim is private
dialogInstance.scrim.style.zIndex = 0;
const { getOpenAnimation, getCloseAnimation } = dialogInstance;
dialogInstance.getOpenAnimation = () => {
const animations = getOpenAnimation.call(this);
animations.container = [
...(animations.container ?? []),
...(animations.dialog ?? []),
];
animations.dialog = [];
return animations;
};
dialogInstance.getCloseAnimation = () => {
const animations = getCloseAnimation.call(this);
animations.container = [
...(animations.container ?? []),
...(animations.dialog ?? []),
];
animations.dialog = [];
return animations;
};
});
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
let DIALOG_POLYFILL: Promise<typeof import("dialog-polyfill")>;
/**
* Based on the home assistant design: https://design.home-assistant.io/#components/ha-dialogs
*
*/
@customElement("ha-md-dialog")
export class HaMdDialog extends Dialog {
/**
* When true the dialog will not close when the user presses the esc key or press out of the dialog.
*/
@property({ attribute: "disable-cancel-action", type: Boolean })
public disableCancelAction = false;
private _polyfillDialogRegistered = false;
constructor() {
super();
this.addEventListener("cancel", this._handleCancel);
if (typeof HTMLDialogElement !== "function") {
this.addEventListener("open", this._handleOpen);
if (!DIALOG_POLYFILL) {
DIALOG_POLYFILL = import("dialog-polyfill");
}
}
// if browser doesn't support animate API disable open/close animations
if (this.animate === undefined) {
this.quick = true;
}
// if browser doesn't support animate API disable open/close animations
if (this.animate === undefined) {
this.quick = true;
}
}
// prevent open in older browsers and wait for polyfill to load
private async _handleOpen(openEvent: Event) {
openEvent.preventDefault();
if (this._polyfillDialogRegistered) {
return;
}
this._polyfillDialogRegistered = true;
this._loadPolyfillStylesheet("/static/polyfills/dialog-polyfill.css");
const dialog = this.shadowRoot?.querySelector(
"dialog"
) as HTMLDialogElement;
const dialogPolyfill = await DIALOG_POLYFILL;
dialogPolyfill.default.registerDialog(dialog);
this.removeEventListener("open", this._handleOpen);
this.show();
}
private async _loadPolyfillStylesheet(href) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
return new Promise<void>((resolve, reject) => {
link.onload = () => resolve();
link.onerror = () =>
reject(new Error(`Stylesheet failed to load: ${href}`));
this.shadowRoot?.appendChild(link);
});
}
private _handleCancel(closeEvent: Event) {
if (this.disableCancelAction) {
closeEvent.preventDefault();
const dialogElement = this.shadowRoot?.querySelector("dialog .container");
if (this.animate !== undefined) {
dialogElement?.animate(
[
{
transform: "rotate(-1deg)",
"animation-timing-function": "ease-in",
},
{
transform: "rotate(1.5deg)",
"animation-timing-function": "ease-out",
},
{
transform: "rotate(0deg)",
"animation-timing-function": "ease-in",
},
],
{
duration: 200,
iterations: 2,
}
);
}
}
}
static override styles = [
styles,
css`
:host {
--md-dialog-container-color: var(--card-background-color);
--md-dialog-headline-color: var(--primary-text-color);
--md-dialog-supporting-text-color: var(--primary-text-color);
--md-sys-color-scrim: #000000;
--md-dialog-headline-weight: var(--ha-font-weight-normal);
--md-dialog-headline-size: var(--ha-font-size-xl);
--md-dialog-supporting-text-size: var(--ha-font-size-m);
--md-dialog-supporting-text-line-height: var(--ha-line-height-normal);
--md-divider-color: var(--divider-color);
}
:host([type="alert"]) {
min-width: 320px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
:host(:not([type="alert"])) {
min-width: var(--mdc-dialog-min-width, 100vw);
min-height: 100%;
max-height: 100%;
--md-dialog-container-shape: 0;
}
.container {
margin-top: var(--safe-area-inset-top, 0);
margin-bottom: var(--safe-area-inset-bottom, 0);
margin-left: var(--safe-area-inset-left, 0);
margin-right: var(--safe-area-inset-right, 0);
}
}
::slotted(ha-dialog-header[slot="headline"]) {
display: contents;
}
slot[name="actions"]::slotted(*) {
padding: var(--ha-space-4);
}
.scroller {
overflow: var(--dialog-content-overflow, auto);
}
slot[name="content"]::slotted(*) {
padding: var(--dialog-content-padding, var(--ha-space-6));
}
.scrim {
z-index: 10; /* overlay navigation */
}
`,
];
}
// by default the dialog open/close animation will be from/to the top
// but if we have a special mobile dialog which is at the bottom of the screen, a from bottom animation can be used:
const OPEN_FROM_BOTTOM_ANIMATION: DialogAnimation = {
...DIALOG_DEFAULT_OPEN_ANIMATION,
dialog: [
[
// Dialog slide up
[{ transform: "translateY(50px)" }, { transform: "translateY(0)" }],
{ duration: 500, easing: "cubic-bezier(.3,0,0,1)" },
],
],
container: [
[
// Container fade in
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 50, easing: "linear", pseudoElement: "::before" },
],
],
};
const CLOSE_TO_BOTTOM_ANIMATION: DialogAnimation = {
...DIALOG_DEFAULT_CLOSE_ANIMATION,
dialog: [
[
// Dialog slide down
[{ transform: "translateY(0)" }, { transform: "translateY(50px)" }],
{ duration: 150, easing: "cubic-bezier(.3,0,0,1)" },
],
],
container: [
[
// Container fade out
[{ opacity: "1" }, { opacity: "0" }],
{ delay: 100, duration: 50, easing: "linear", pseudoElement: "::before" },
],
],
};
export const getMobileOpenFromBottomAnimation = () => {
const matches = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
return matches ? OPEN_FROM_BOTTOM_ANIMATION : DIALOG_DEFAULT_OPEN_ANIMATION;
};
export const getMobileCloseToBottomAnimation = () => {
const matches = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
return matches ? CLOSE_TO_BOTTOM_ANIMATION : DIALOG_DEFAULT_CLOSE_ANIMATION;
};
declare global {
interface HTMLElementTagNameMap {
"ha-md-dialog": HaMdDialog;
}
}

View File

@@ -0,0 +1,22 @@
import { Divider } from "@material/web/divider/internal/divider";
import { styles } from "@material/web/divider/internal/divider-styles";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-md-divider")
export class HaMdDivider extends Divider {
static override styles = [
styles,
css`
:host {
--md-divider-color: var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-md-divider": HaMdDivider;
}
}

View File

@@ -0,0 +1,52 @@
import { MenuItemEl } from "@material/web/menu/internal/menuitem/menu-item";
import { styles } from "@material/web/menu/internal/menuitem/menu-item-styles";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-md-menu-item")
export class HaMdMenuItem extends MenuItemEl {
@property({ attribute: false }) clickAction?: (item?: HTMLElement) => void;
static override styles = [
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-primary: var(--primary-text-color);
--md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-surface: var(--card-background-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-sys-color-secondary-container: rgba(
var(--rgb-primary-color),
0.15
);
--md-sys-color-on-secondary-container: var(--text-primary-color);
--mdc-icon-size: 16px;
--md-sys-color-on-primary-container: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
--md-menu-item-label-text-font: Roboto, sans-serif;
}
:host(.warning) {
--md-menu-item-label-text-color: var(--error-color);
--md-menu-item-leading-icon-color: var(--error-color);
}
::slotted([slot="headline"]) {
text-wrap: nowrap;
}
:host([disabled]) {
opacity: 1;
--md-menu-item-label-text-color: var(--disabled-text-color);
--md-menu-item-leading-icon-color: var(--disabled-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-md-menu-item": HaMdMenuItem;
}
}

View File

@@ -0,0 +1,47 @@
import { Menu } from "@material/web/menu/internal/menu";
import { styles } from "@material/web/menu/internal/menu-styles";
import type { CloseMenuEvent } from "@material/web/menu/menu";
import {
CloseReason,
KeydownCloseKey,
} from "@material/web/menu/internal/controllers/shared";
import { css } from "lit";
import { customElement } from "lit/decorators";
import type { HaMdMenuItem } from "./ha-md-menu-item";
@customElement("ha-md-menu")
export class HaMdMenu extends Menu {
connectedCallback(): void {
super.connectedCallback();
this.addEventListener("close-menu", this._handleCloseMenu);
}
private _handleCloseMenu(ev: CloseMenuEvent) {
if (
ev.detail.reason.kind === CloseReason.KEYDOWN &&
ev.detail.reason.key === KeydownCloseKey.ESCAPE
) {
return;
}
(ev.detail.initiator as HaMdMenuItem).clickAction?.(ev.detail.initiator);
}
static override styles = [
styles,
css`
:host {
--md-sys-color-surface-container: var(--card-background-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-md-menu": HaMdMenu;
}
interface HTMLElementEventMap {
"close-menu": CloseMenuEvent;
}
}

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