diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 516e8c6704..b59debf690 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -55,9 +55,19 @@ jobs: rm -rf dist home_assistant_frontend.egg-info python3 -m build + - name: Archive translations + run: tar -czvf translations.tar.gz translations + - name: Upload build artifacts uses: actions/upload-artifact@v3 with: name: wheels path: dist/home_assistant_frontend*.whl if-no-files-found: error + + - name: Upload translations + uses: actions/upload-artifact@v3 + with: + name: translations + path: translations.tar.gz + if-no-files-found: error diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..cf5c994491 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +yarn run lint-staged --relative --shell "/bin/bash" diff --git a/build-scripts/webpack.js b/build-scripts/webpack.js index c5d741a5af..8ad85275d8 100644 --- a/build-scripts/webpack.js +++ b/build-scripts/webpack.js @@ -76,7 +76,7 @@ const createWebpackConfig = ({ chunkIds: isProdBuild && !isStatsBuild ? "deterministic" : "named", }, plugins: [ - new WebpackBar({ fancy: !isProdBuild }), + !isStatsBuild && new WebpackBar({ fancy: !isProdBuild }), new WebpackManifestPlugin({ // Only include the JS of entrypoints filter: (file) => file.isInitial && !file.name.endsWith(".map"), diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index 625ddc6187..4cbc5ef266 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -61,6 +61,7 @@ class HaDemo extends HomeAssistantAppEl { area_id: null, disabled_by: null, entity_id: "sensor.co2_intensity", + unique_id: "sensor.co2_intensity", name: null, icon: null, platform: "co2signal", @@ -74,6 +75,7 @@ class HaDemo extends HomeAssistantAppEl { area_id: null, disabled_by: null, entity_id: "sensor.grid_fossil_fuel_percentage", + unique_id: "sensor.co2_intensity", name: null, icon: null, platform: "co2signal", diff --git a/gallery/src/ha-gallery.ts b/gallery/src/ha-gallery.ts index 4f22bac519..31f2337a12 100644 --- a/gallery/src/ha-gallery.ts +++ b/gallery/src/ha-gallery.ts @@ -5,7 +5,7 @@ import { html, css, LitElement, PropertyValues } from "lit"; import { customElement, property, query } from "lit/decorators"; import "../../src/components/ha-icon-button"; import "../../src/managers/notification-manager"; -import "../../src/components/ha-expansion-panel"; +import { HaExpansionPanel } from "../../src/components/ha-expansion-panel"; import { haStyle } from "../../src/resources/styles"; import { PAGES, SIDEBAR } from "../build/import-pages"; import { dynamicElement } from "../../src/common/dom/dynamic-element-directive"; @@ -174,9 +174,10 @@ class HaGallery extends LitElement { const menuItem = this.shadowRoot!.querySelector( `a[href="#${this._page}"]` )!; + // Make sure section is expanded - if (menuItem.parentElement instanceof HTMLDetailsElement) { - menuItem.parentElement.open = true; + if (menuItem.parentElement instanceof HaExpansionPanel) { + menuItem.parentElement.expanded = true; } } diff --git a/gallery/src/pages/automation/describe-action.ts b/gallery/src/pages/automation/describe-action.ts index 49fa3dc7f9..1a32ac0829 100644 --- a/gallery/src/pages/automation/describe-action.ts +++ b/gallery/src/pages/automation/describe-action.ts @@ -1,7 +1,9 @@ import { dump } from "js-yaml"; import { html, css, LitElement, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-yaml-editor"; +import { Action } from "../../../../src/data/script"; import { describeAction } from "../../../../src/data/script_i18n"; import { getEntity } from "../../../../src/fake_data/entity"; import { provideHass } from "../../../../src/fake_data/provide_hass"; @@ -88,6 +90,15 @@ const ACTIONS = [ then: [{ delay: "00:00:01" }], else: [{ delay: "00:00:05" }], }, + { + if: [{ condition: "state" }], + then: [{ delay: "00:00:01" }], + }, + { + if: [{ condition: "state" }, { condition: "state" }], + then: [{ delay: "00:00:01" }], + else: [{ delay: "00:00:05" }], + }, { choose: [ { @@ -103,16 +114,38 @@ const ACTIONS = [ }, ]; +const initialAction: Action = { + service: "light.turn_on", + target: { + entity_id: "light.kitchen", + }, +}; + @customElement("demo-automation-describe-action") export class DemoAutomationDescribeAction extends LitElement { @property({ attribute: false }) hass!: HomeAssistant; + @state() _action = initialAction; + protected render(): TemplateResult { if (!this.hass) { return html``; } return html` +
+ + ${this._action + ? describeAction(this.hass, this._action) + : ""} + + +
+ ${ACTIONS.map( (conf) => html`
@@ -132,6 +165,11 @@ export class DemoAutomationDescribeAction extends LitElement { hass.addEntities(ENTITIES); } + private _dataChanged(ev: CustomEvent): void { + ev.stopPropagation(); + this._action = ev.detail.isValid ? ev.detail.value : undefined; + } + static get styles() { return css` ha-card { @@ -147,6 +185,9 @@ export class DemoAutomationDescribeAction extends LitElement { span { margin-right: 16px; } + ha-yaml-editor { + width: 50%; + } `; } } diff --git a/gallery/src/pages/automation/describe-condition.ts b/gallery/src/pages/automation/describe-condition.ts index 11de8b9781..2f6cef5f47 100644 --- a/gallery/src/pages/automation/describe-condition.ts +++ b/gallery/src/pages/automation/describe-condition.ts @@ -1,31 +1,81 @@ import { dump } from "js-yaml"; -import { html, css, LitElement, TemplateResult } from "lit"; -import { customElement } from "lit/decorators"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-yaml-editor"; +import { Condition } from "../../../../src/data/automation"; import { describeCondition } from "../../../../src/data/automation_i18n"; +import { getEntity } from "../../../../src/fake_data/entity"; +import { provideHass } from "../../../../src/fake_data/provide_hass"; +import { HomeAssistant } from "../../../../src/types"; + +const ENTITIES = [ + getEntity("light", "kitchen", "on", { + friendly_name: "Kitchen Light", + }), + getEntity("device_tracker", "person", "home", { + friendly_name: "Person", + }), + getEntity("zone", "home", "", { + friendly_name: "Home", + }), +]; const conditions = [ { condition: "and" }, { condition: "not" }, { condition: "or" }, - { condition: "state" }, - { condition: "numeric_state" }, + { condition: "state", entity_id: "light.kitchen", state: "on" }, + { + condition: "numeric_state", + entity_id: "light.kitchen", + attribute: "brightness", + below: 80, + above: 20, + }, { condition: "sun", after: "sunset" }, - { condition: "sun", after: "sunrise" }, - { condition: "zone" }, + { condition: "sun", after: "sunrise", offset: "-01:00" }, + { condition: "zone", entity_id: "device_tracker.person", zone: "zone.home" }, { condition: "time" }, { condition: "template" }, ]; +const initialCondition: Condition = { + condition: "state", + entity_id: "light.kitchen", + state: "on", +}; + @customElement("demo-automation-describe-condition") export class DemoAutomationDescribeCondition extends LitElement { + @property({ attribute: false }) hass!: HomeAssistant; + + @state() _condition = initialCondition; + protected render(): TemplateResult { + if (!this.hass) { + return html``; + } + return html` +
+ + ${this._condition + ? describeCondition(this._condition, this.hass) + : ""} + + +
+ ${conditions.map( (conf) => html`
- ${describeCondition(conf as any)} + ${describeCondition(conf as any, this.hass)}
${dump(conf)}
` @@ -34,6 +84,18 @@ export class DemoAutomationDescribeCondition extends LitElement { `; } + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + const hass = provideHass(this); + hass.updateTranslations(null, "en"); + hass.addEntities(ENTITIES); + } + + private _dataChanged(ev: CustomEvent): void { + ev.stopPropagation(); + this._condition = ev.detail.isValid ? ev.detail.value : undefined; + } + static get styles() { return css` ha-card { @@ -49,6 +111,9 @@ export class DemoAutomationDescribeCondition extends LitElement { span { margin-right: 16px; } + ha-yaml-editor { + width: 50%; + } `; } } diff --git a/gallery/src/pages/automation/describe-trigger.ts b/gallery/src/pages/automation/describe-trigger.ts index 6bfe3213c3..081f6162a7 100644 --- a/gallery/src/pages/automation/describe-trigger.ts +++ b/gallery/src/pages/automation/describe-trigger.ts @@ -1,34 +1,92 @@ import { dump } from "js-yaml"; -import { html, css, LitElement, TemplateResult } from "lit"; -import { customElement } from "lit/decorators"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-yaml-editor"; +import { Trigger } from "../../../../src/data/automation"; import { describeTrigger } from "../../../../src/data/automation_i18n"; +import { getEntity } from "../../../../src/fake_data/entity"; +import { provideHass } from "../../../../src/fake_data/provide_hass"; +import { HomeAssistant } from "../../../../src/types"; + +const ENTITIES = [ + getEntity("light", "kitchen", "on", { + friendly_name: "Kitchen Light", + }), + getEntity("person", "person", "", { + friendly_name: "Person", + }), + getEntity("zone", "home", "", { + friendly_name: "Home", + }), +]; const triggers = [ - { platform: "state" }, + { platform: "state", entity_id: "light.kitchen", from: "off", to: "on" }, { platform: "mqtt" }, - { platform: "geo_location" }, - { platform: "homeassistant" }, - { platform: "numeric_state" }, - { platform: "sun" }, + { + platform: "geo_location", + source: "test_source", + zone: "zone.home", + event: "enter", + }, + { platform: "homeassistant", event: "start" }, + { + platform: "numeric_state", + entity_id: "light.kitchen", + attribute: "brightness", + below: 80, + above: 20, + }, + { platform: "sun", event: "sunset" }, { platform: "time_pattern" }, { platform: "webhook" }, - { platform: "zone" }, + { + platform: "zone", + entity_id: "person.person", + zone: "zone.home", + event: "enter", + }, { platform: "tag" }, - { platform: "time" }, + { platform: "time", at: "15:32" }, { platform: "template" }, - { platform: "event" }, + { platform: "event", event_type: "homeassistant_started" }, ]; +const initialTrigger: Trigger = { + platform: "state", + entity_id: "light.kitchen", +}; + @customElement("demo-automation-describe-trigger") export class DemoAutomationDescribeTrigger extends LitElement { + @property({ attribute: false }) hass!: HomeAssistant; + + @state() _trigger = initialTrigger; + protected render(): TemplateResult { + if (!this.hass) { + return html``; + } + return html` +
+ + ${this._trigger + ? describeTrigger(this._trigger, this.hass) + : ""} + + +
${triggers.map( (conf) => html`
- ${describeTrigger(conf as any)} + ${describeTrigger(conf as any, this.hass)}
${dump(conf)}
` @@ -37,6 +95,18 @@ export class DemoAutomationDescribeTrigger extends LitElement { `; } + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + const hass = provideHass(this); + hass.updateTranslations(null, "en"); + hass.addEntities(ENTITIES); + } + + private _dataChanged(ev: CustomEvent): void { + ev.stopPropagation(); + this._trigger = ev.detail.isValid ? ev.detail.value : undefined; + } + static get styles() { return css` ha-card { @@ -52,6 +122,9 @@ export class DemoAutomationDescribeTrigger extends LitElement { span { margin-right: 16px; } + ha-yaml-editor { + width: 50%; + } `; } } diff --git a/gallery/src/pages/components/dialogs.markdown b/gallery/src/pages/components/dialogs.markdown new file mode 100644 index 0000000000..0558dc724f --- /dev/null +++ b/gallery/src/pages/components/dialogs.markdown @@ -0,0 +1,32 @@ +--- +title: Dialgos +subtitle: Dialogs provide important prompts in a user flow. +--- + +# Material Desing 3 + +Our dialogs are based on the latest version of Material Design. Specs and guidelines can be found on it's [website](https://m3.material.io/components/dialogs/overview). + +# Highlighted guidelines + +## Content +* A best practice is to always use a title, even if it is optional by Material guidelines. +* People mainly read the title and a button. Put the most important information in those two. +* Try to avoid user generated content in the title, this could make the title unreadable long. +* If users become unsure, they read the description. Make sure this explains what will happen. +* Strive for minimalism. + +## Buttons and X-icon +* Keep the labels short, for example `Save`, `Delete`, `Enable`. +* Dialog with actions must always have a discard button. On desktop a `Cancel` button and X-icon, on mobile only the X-icon. +* Destructive actions should be a red warning button. +* Alert or confirmation dialogs only have buttons and no X-icon. +* Try to avoid three buttons in one dialog. Especially when you leave the dialog task unfinished. + +## Example +### Confirmation dialog +> **Delete dashboard?** +> +> Dashboard [dashboard name] will be permanently deleted from Home Assistant. +> +> Cancel / Delete diff --git a/gallery/src/pages/components/ha-alert.markdown b/gallery/src/pages/components/ha-alert.markdown index e37a205481..2f608d1b17 100644 --- a/gallery/src/pages/components/ha-alert.markdown +++ b/gallery/src/pages/components/ha-alert.markdown @@ -3,6 +3,13 @@ title: Alerts subtitle: An alert displays a short, important message in a way that attracts the user's attention without interrupting the user's task. --- + + # Alert `` The alert offers four severity levels that set a distinctive icon and color. diff --git a/gallery/src/pages/components/ha-expansion-panel.markdown b/gallery/src/pages/components/ha-expansion-panel.markdown new file mode 100644 index 0000000000..275627e6a2 --- /dev/null +++ b/gallery/src/pages/components/ha-expansion-panel.markdown @@ -0,0 +1,5 @@ +--- +title: Expansion Panel +--- + +Expansion panel following all the ARIA guidelines. diff --git a/gallery/src/pages/components/ha-expansion-panel.ts b/gallery/src/pages/components/ha-expansion-panel.ts new file mode 100644 index 0000000000..781aa063bb --- /dev/null +++ b/gallery/src/pages/components/ha-expansion-panel.ts @@ -0,0 +1,157 @@ +import { mdiPacMan } from "@mdi/js"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement } from "lit/decorators"; +import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-expansion-panel"; +import "../../../../src/components/ha-markdown"; +import "../../components/demo-black-white-row"; +import { LONG_TEXT } from "../../data/text"; + +const SHORT_TEXT = LONG_TEXT.substring(0, 113); + +const SAMPLES: { + template: (slot: string, leftChevron: boolean) => TemplateResult; +}[] = [ + { + template(slot, leftChevron) { + return html` + + ${SHORT_TEXT} + + `; + }, + }, + { + template(slot, leftChevron) { + return html` + + ${SHORT_TEXT} + + `; + }, + }, + { + template(slot, leftChevron) { + return html` + + ${SHORT_TEXT} + + `; + }, + }, + { + template(slot, leftChevron) { + return html` + + ${SHORT_TEXT} + + `; + }, + }, + { + template(slot, leftChevron) { + return html` + + Slot Secondary + ${SHORT_TEXT} + + `; + }, + }, + { + template(slot, leftChevron) { + return html` + + Slot header + ${SHORT_TEXT} + + `; + }, + }, + { + template(slot, leftChevron) { + return html` + + Slot header with actions + + ${SHORT_TEXT} + + `; + }, + }, + { + template(slot, leftChevron) { + return html` + + + ${SHORT_TEXT} + + `; + }, + }, +]; + +@customElement("demo-components-ha-expansion-panel") +export class DemoHaExpansionPanel extends LitElement { + protected render(): TemplateResult { + return html` + ${SAMPLES.map( + (sample) => html` + + ${["light", "dark"].map((slot) => + sample.template(slot, slot === "dark") + )} + + ` + )} + `; + } + + static get styles() { + return css` + ha-expansion-panel { + margin: -16px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-expansion-panel": DemoHaExpansionPanel; + } +} diff --git a/gallery/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts index 4d8ad42b73..5ddf38b765 100644 --- a/gallery/src/pages/components/ha-form.ts +++ b/gallery/src/pages/components/ha-form.ts @@ -3,6 +3,7 @@ import "@material/mwc-button"; import { html, LitElement, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators"; import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; +import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; @@ -20,16 +21,22 @@ const ENTITIES = [ }), getEntity("media_player", "livingroom", "playing", { friendly_name: "Livingroom", + media_content_type: "music", + device_class: "tv", }), getEntity("media_player", "lounge", "idle", { friendly_name: "Lounge", supported_features: 444983, + device_class: "speaker", }), getEntity("light", "bedroom", "on", { friendly_name: "Bedroom", + effect: "colorloop", + effect_list: ["colorloop", "random"], }), getEntity("switch", "coffee", "off", { friendly_name: "Coffee", + device_class: "switch", }), ]; @@ -141,7 +148,13 @@ const SCHEMAS: { selector: { attribute: { entity_id: "" } }, context: { filter_entity: "entity" }, }, + { + name: "State", + selector: { state: { entity_id: "" } }, + context: { filter_entity: "entity", filter_attribute: "Attribute" }, + }, { name: "Device", selector: { device: {} } }, + { name: "Config entry", selector: { config_entry: {} } }, { name: "Duration", selector: { duration: {} } }, { name: "area", selector: { area: {} } }, { name: "target", selector: { target: {} } }, @@ -423,6 +436,7 @@ class DemoHaForm extends LitElement { hass.addEntities(ENTITIES); mockEntityRegistry(hass); mockDeviceRegistry(hass, DEVICES); + mockConfigEntries(hass); mockAreaRegistry(hass, AREAS); mockHassioSupervisor(hass); } diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 6ce04e0995..dcc099b636 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -3,6 +3,7 @@ import "@material/mwc-button"; import { css, html, LitElement, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators"; import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; +import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; @@ -115,11 +116,19 @@ const SCHEMAS: { name: "One of each", input: { entity: { name: "Entity", selector: { entity: {} } }, + state: { + name: "State", + selector: { state: { entity_id: "alarm_control_panel.alarm" } }, + }, attribute: { name: "Attribute", selector: { attribute: { entity_id: "" } }, }, device: { name: "Device", selector: { device: {} } }, + config_entry: { + name: "Integration", + selector: { config_entry: {} }, + }, duration: { name: "Duration", selector: { duration: {} } }, addon: { name: "Addon", selector: { addon: {} } }, area: { name: "Area", selector: { area: {} } }, @@ -276,6 +285,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement { hass.addEntities(ENTITIES); mockEntityRegistry(hass); mockDeviceRegistry(hass, DEVICES); + mockConfigEntries(hass); mockAreaRegistry(hass, AREAS); mockHassioSupervisor(hass); hass.mockWS("auth/sign_path", (params) => params); diff --git a/gallery/src/pages/lovelace/entities-card.ts b/gallery/src/pages/lovelace/entities-card.ts index a4fc76a510..6cec33cba5 100644 --- a/gallery/src/pages/lovelace/entities-card.ts +++ b/gallery/src/pages/lovelace/entities-card.ts @@ -75,6 +75,10 @@ const ENTITIES = [ timestamp: 1641801600, friendly_name: "Date and Time", }), + getEntity("sensor", "humidity", "23.2", { + friendly_name: "Humidity", + unit_of_measurement: "%", + }), getEntity("input_select", "dropdown", "Soda", { friendly_name: "Dropdown", options: ["Soda", "Beer", "Wine"], @@ -142,6 +146,7 @@ const CONFIGS = [ - light.non_existing - climate.ecobee - input_number.number + - sensor.humidity `, }, { diff --git a/gallery/src/pages/misc/integration-card.ts b/gallery/src/pages/misc/integration-card.ts index cf12cefd35..88f436e2ae 100644 --- a/gallery/src/pages/misc/integration-card.ts +++ b/gallery/src/pages/misc/integration-card.ts @@ -191,6 +191,7 @@ const createEntityRegistryEntries = ( hidden_by: null, entity_category: null, entity_id: "binary_sensor.updater", + unique_id: "binary_sensor.updater", name: null, icon: null, platform: "updater", diff --git a/hassio/src/addon-store/hassio-addon-store.ts b/hassio/src/addon-store/hassio-addon-store.ts index 2c373a4e9f..0623e2bf44 100644 --- a/hassio/src/addon-store/hassio-addon-store.ts +++ b/hassio/src/addon-store/hassio-addon-store.ts @@ -22,8 +22,10 @@ import { HassioAddonRepository, reloadHassioAddons, } from "../../../src/data/hassio/addon"; +import { extractApiErrorMessage } from "../../../src/data/hassio/common"; import { StoreAddon } from "../../../src/data/supervisor/store"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; +import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box"; import "../../../src/layouts/hass-loading-screen"; import "../../../src/layouts/hass-subpage"; import { HomeAssistant, Route } from "../../../src/types"; @@ -59,8 +61,15 @@ class HassioAddonStore extends LitElement { @state() private _filter?: string; public async refreshData() { - await reloadHassioAddons(this.hass); - await this._loadData(); + try { + await reloadHassioAddons(this.hass); + } catch (err) { + showAlertDialog(this, { + text: extractApiErrorMessage(err), + }); + } finally { + await this._loadData(); + } } protected render(): TemplateResult { diff --git a/hassio/src/addon-view/hassio-addon-dashboard.ts b/hassio/src/addon-view/hassio-addon-dashboard.ts index 98a6c8a843..4fb8f46178 100644 --- a/hassio/src/addon-view/hassio-addon-dashboard.ts +++ b/hassio/src/addon-view/hassio-addon-dashboard.ts @@ -75,7 +75,7 @@ class HassioAddonDashboard extends LitElement { >`; } - if (!this.addon) { + if (!this.addon || !this.supervisor?.addon) { return html``; } @@ -209,8 +209,8 @@ class HassioAddonDashboard extends LitElement { } if (requestedAddon) { - const addonsInfo = await fetchHassioAddonsInfo(this.hass); - const validAddon = addonsInfo.addons.some( + const store = await fetchSupervisorStore(this.hass); + const validAddon = store.addons.some( (addon) => addon.slug === requestedAddon ); if (!validAddon) { @@ -238,7 +238,7 @@ class HassioAddonDashboard extends LitElement { if (["uninstall", "install", "update", "start", "stop"].includes(path)) { fireEvent(this, "supervisor-collection-refresh", { - collection: "supervisor", + collection: "addon", }); } @@ -263,6 +263,10 @@ class HassioAddonDashboard extends LitElement { return; } try { + if (!this.supervisor.addon) { + const addonsInfo = await fetchHassioAddonsInfo(this.hass); + fireEvent(this, "supervisor-update", { addon: addonsInfo }); + } this.addon = await fetchAddonInfo(this.hass, this.supervisor, addon); } catch (err: any) { this._error = `Error fetching addon info: ${extractApiErrorMessage(err)}`; diff --git a/hassio/src/addon-view/info/hassio-addon-info.ts b/hassio/src/addon-view/info/hassio-addon-info.ts index 6675d7fd55..3753b8a42d 100644 --- a/hassio/src/addon-view/info/hassio-addon-info.ts +++ b/hassio/src/addon-view/info/hassio-addon-info.ts @@ -40,6 +40,7 @@ import "../../../../src/components/ha-settings-row"; import "../../../../src/components/ha-svg-icon"; import "../../../../src/components/ha-switch"; import { + AddonCapability, fetchHassioAddonChangelog, fetchHassioAddonInfo, HassioAddonDetails, @@ -701,7 +702,7 @@ class HassioAddonInfo extends LitElement { } private _showMoreInfo(ev): void { - const id = ev.currentTarget.id; + const id = ev.currentTarget.id as AddonCapability; showHassioMarkdownDialog(this, { title: this.supervisor.localize(`addon.dashboard.capability.${id}.title`), content: diff --git a/hassio/src/backups/hassio-backups.ts b/hassio/src/backups/hassio-backups.ts index 99d2869f8b..f2b0466623 100644 --- a/hassio/src/backups/hassio-backups.ts +++ b/hassio/src/backups/hassio-backups.ts @@ -176,7 +176,7 @@ export class HassioBackups extends LitElement { : supervisorTabs(this.hass)} .hass=${this.hass} .localizeFunc=${this.supervisor.localize} - .searchLabel=${this.supervisor.localize("search")} + .searchLabel=${this.supervisor.localize("backup.search")} .noDataText=${this.supervisor.localize("backup.no_backups")} .narrow=${this.narrow} .route=${this.route} @@ -240,7 +240,7 @@ export class HassioBackups extends LitElement { : html` - this.supervisor?.localize(`backup.${string}`) || - this.localize!(`ui.panel.page-onboarding.restore.${string}`); + private _localize = (key: BackupOrRestoreKey) => + this.supervisor?.localize(`backup.${key}`) || + this.localize!(`ui.panel.page-onboarding.restore.${key}`); protected render(): TemplateResult { if (!this.onboarding && !this.supervisor) { @@ -168,7 +171,7 @@ export class SupervisorBackupContent extends LitElement { : ""} ${this.backupType === "partial" ? html`
- ${this.backup?.homeassistant + ${!this.backup || this.backup.homeassistant ? html` - this.supervisor.localize(`dialog.registries.${schema.name}`) || schema.name; + private _computeLabel = (schema: SchemaUnion) => + this.supervisor.localize(`dialog.registries.${schema.name}`); private _valueChanged(ev: CustomEvent) { this._input = ev.detail.value; diff --git a/hassio/src/supervisor-base-element.ts b/hassio/src/supervisor-base-element.ts index 7d9ee78d07..cdb33daefa 100644 --- a/hassio/src/supervisor-base-element.ts +++ b/hassio/src/supervisor-base-element.ts @@ -22,10 +22,11 @@ import { Supervisor, SupervisorObject, supervisorCollection, + SupervisorKeys, } from "../../src/data/supervisor/supervisor"; import { ProvideHassLitMixin } from "../../src/mixins/provide-hass-lit-mixin"; import { urlSyncMixin } from "../../src/state/url-sync-mixin"; -import { HomeAssistant, Route, TranslationDict } from "../../src/types"; +import { HomeAssistant, Route } from "../../src/types"; import { getTranslation } from "../../src/util/common-translation"; declare global { @@ -124,7 +125,7 @@ export class SupervisorBaseElement extends urlSyncMixin( this.supervisor = { ...this.supervisor, - localize: await computeLocalize( + localize: await computeLocalize( this.constructor.prototype, language, { diff --git a/lint-staged.config.js b/lint-staged.config.js index 187d2b1d5b..cfec1fbeac 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -1,4 +1,11 @@ module.exports = { - "*.{js,ts}": 'eslint --ignore-pattern "**/build-scripts/**/*.js" --fix', - "!(/translations)*.{js,ts,json,css,md,html}": "prettier --write", + "*.{js,ts}": [ + "prettier --write", + 'eslint --ignore-pattern "**/build-scripts/**/*.js" --fix', + ], + "!(/translations)*.{json,css,md,html}": "prettier --write", + "translations/*/*.json": (files) => + 'printf "%s\n" "These files should not be modified. Instead, make the necessary modifications in src/translations/en.json. Please see translations/README.md for details." ' + + files.join(" ") + + " >&2 && exit 1", }; diff --git a/package.json b/package.json index 2b7bbf967a..f2f7535f85 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "lint:lit": "lit-analyzer \"**/src/**/*.ts\" --format markdown --outFile result.md", "lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types", "format": "yarn run format:eslint && yarn run format:prettier", + "postinstall": "husky install", + "prepack": "pinst --disable", + "postpack": "pinst --enable", "test": "instant-mocha --webpack-config ./test/webpack.config.js --require ./test/setup.js \"test/**/*.ts\"" }, "author": "Paulus Schoutsen (http://paulusschoutsen.nl)", @@ -46,6 +49,7 @@ "@fullcalendar/daygrid": "5.9.0", "@fullcalendar/interaction": "5.9.0", "@fullcalendar/list": "5.9.0", + "@fullcalendar/timegrid": "5.9.0", "@lit-labs/motion": "^1.0.2", "@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.7.0-pre.2#./.yarn/patches/@lit-labs/virtualizer/event-target-shim.patch", "@material/chips": "14.0.0-canary.261f2db59.0", @@ -89,8 +93,8 @@ "@polymer/paper-tooltip": "^3.0.1", "@polymer/polymer": "3.4.1", "@thomasloven/round-slider": "0.5.4", - "@vaadin/combo-box": "^23.0.10", - "@vaadin/vaadin-themable-mixin": "^23.0.10", + "@vaadin/combo-box": "^23.1.5", + "@vaadin/vaadin-themable-mixin": "^23.1.5", "@vibrant/color": "^3.2.1-alpha.1", "@vibrant/core": "^3.2.1-alpha.1", "@vibrant/quantizer-mmcq": "^3.2.1-alpha.1", @@ -107,15 +111,14 @@ "deep-freeze": "^0.0.1", "fuse.js": "^6.0.0", "google-timezones-json": "^1.0.2", - "hls.js": "^1.1.5", - "home-assistant-js-websocket": "^7.1.0", + "hls.js": "^1.2.1", + "home-assistant-js-websocket": "^8.0.0", "idb-keyval": "^5.1.3", "intl-messageformat": "^9.9.1", "js-yaml": "^4.1.0", "leaflet": "^1.7.1", "leaflet-draw": "^1.0.4", "lit": "^2.1.2", - "lit-vaadin-helpers": "^0.3.0", "marked": "^4.0.12", "memoize-one": "^5.2.1", "node-vibrant": "3.2.1-alpha.1", @@ -202,9 +205,9 @@ "gulp-rename": "^2.0.0", "gulp-zopfli-green": "^3.0.1", "html-minifier": "^4.0.0", - "husky": "^1.3.1", + "husky": "^8.0.1", "instant-mocha": "^1.3.1", - "lint-staged": "^11.1.2", + "lint-staged": "^13.0.3", "lit-analyzer": "^1.2.1", "lodash.template": "^4.5.0", "magic-string": "^0.25.7", @@ -213,6 +216,7 @@ "mocha": "^8.4.0", "object-hash": "^2.0.3", "open": "^7.0.4", + "pinst": "^3.0.0", "prettier": "^2.4.1", "require-dir": "^1.2.0", "rollup": "^2.8.2", @@ -245,11 +249,6 @@ "@lit/reactive-element": "1.2.1" }, "main": "src/home-assistant.js", - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } - }, "prettier": { "trailingComma": "es5", "arrowParens": "always" diff --git a/pyproject.toml b/pyproject.toml index 2666a9c73f..a6643d62a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20220802.0" +version = "20220831.0" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md" diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index 6f4e2a1367..297eaeeb19 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -314,7 +314,8 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { } private _computeStepDescription(step: DataEntryFlowStepForm) { - const resourceKey = `ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.description`; + const resourceKey = + `ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.description` as const; const args: string[] = []; const placeholders = step.description_placeholders || {}; Object.keys(placeholders).forEach((key) => { diff --git a/src/common/const.ts b/src/common/const.ts index 68489f95d2..5ee23206f7 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -98,6 +98,7 @@ export const FIXED_DOMAIN_ICONS = { proximity: mdiAppleSafari, remote: mdiRemote, scene: mdiPalette, + schedule: mdiCalendarClock, script: mdiScriptText, select: mdiFormatListBulleted, sensor: mdiEye, @@ -166,46 +167,6 @@ export const DOMAINS_WITH_CARD = [ "water_heater", ]; -/** Domains with separate more info dialog. */ -export const DOMAINS_WITH_MORE_INFO = [ - "alarm_control_panel", - "automation", - "camera", - "climate", - "configurator", - "counter", - "cover", - "fan", - "group", - "humidifier", - "input_datetime", - "light", - "lock", - "media_player", - "person", - "remote", - "script", - "scene", - "sun", - "timer", - "update", - "vacuum", - "water_heater", - "weather", -]; - -/** Domains that do not show the default more info dialog content (e.g. the attribute section) - * and do not have a separate more info (so not in DOMAINS_WITH_MORE_INFO). */ -export const DOMAINS_HIDE_DEFAULT_MORE_INFO = [ - "input_number", - "input_select", - "input_text", - "number", - "scene", - "update", - "select", -]; - /** Domains that render an input element instead of a text value when displayed in a row. * Those rows should then not show a cursor pointer when hovered (which would normally * be the default) unless the element itself enforces it (e.g. a button). Also those elements @@ -237,9 +198,6 @@ export const DOMAINS_INPUT_ROW = [ "vacuum", ]; -/** Domains that should have the history hidden in the more info dialog. */ -export const DOMAINS_MORE_INFO_NO_HISTORY = ["camera", "configurator"]; - /** States that we consider "off". */ export const STATES_OFF = ["closed", "locked", "off"]; diff --git a/src/common/datetime/format_date_time.ts b/src/common/datetime/format_date_time.ts index 3310de0291..9aef50e2c8 100644 --- a/src/common/datetime/format_date_time.ts +++ b/src/common/datetime/format_date_time.ts @@ -1,7 +1,7 @@ import memoizeOne from "memoize-one"; import { FrontendLocaleData } from "../../data/translation"; -import { useAmPm } from "./use_am_pm"; import { polyfillsLoaded } from "../translations/localize"; +import { useAmPm } from "./use_am_pm"; if (__BUILD__ === "latest" && polyfillsLoaded) { await polyfillsLoaded; @@ -28,6 +28,28 @@ const formatDateTimeMem = memoizeOne( ) ); +// Aug 9, 8:23 AM +export const formatShortDateTime = ( + dateObj: Date, + locale: FrontendLocaleData +) => formatShortDateTimeMem(locale).format(dateObj); + +const formatShortDateTimeMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat( + locale.language === "en" && !useAmPm(locale) + ? "en-u-hc-h23" + : locale.language, + { + month: "short", + day: "numeric", + hour: useAmPm(locale) ? "numeric" : "2-digit", + minute: "2-digit", + hour12: useAmPm(locale), + } + ) +); + // August 9, 2021, 8:23:15 AM export const formatDateTimeWithSeconds = ( dateObj: Date, diff --git a/src/common/datetime/format_duration.ts b/src/common/datetime/format_duration.ts new file mode 100644 index 0000000000..c05c890a39 --- /dev/null +++ b/src/common/datetime/format_duration.ts @@ -0,0 +1,28 @@ +import { HaDurationData } from "../../components/ha-duration-input"; + +const leftPad = (num: number) => (num < 10 ? `0${num}` : num); + +export const formatDuration = (duration: HaDurationData) => { + const d = duration.days || 0; + const h = duration.hours || 0; + const m = duration.minutes || 0; + const s = duration.seconds || 0; + const ms = duration.milliseconds || 0; + + if (d > 0) { + return `${d} days ${h}:${leftPad(m)}:${leftPad(s)}`; + } + if (h > 0) { + return `${h}:${leftPad(m)}:${leftPad(s)}`; + } + if (m > 0) { + return `${m}:${leftPad(s)}`; + } + if (s > 0) { + return `${s} seconds`; + } + if (ms > 0) { + return `${ms} milliseconds`; + } + return null; +}; diff --git a/src/common/datetime/format_time.ts b/src/common/datetime/format_time.ts index c49afc7f56..a072ed126c 100644 --- a/src/common/datetime/format_time.ts +++ b/src/common/datetime/format_time.ts @@ -1,7 +1,7 @@ import memoizeOne from "memoize-one"; import { FrontendLocaleData } from "../../data/translation"; -import { useAmPm } from "./use_am_pm"; import { polyfillsLoaded } from "../translations/localize"; +import { useAmPm } from "./use_am_pm"; if (__BUILD__ === "latest" && polyfillsLoaded) { await polyfillsLoaded; @@ -64,3 +64,17 @@ const formatTimeWeekdayMem = memoizeOne( } ) ); + +// 21:15 +export const formatTime24h = (dateObj: Date) => + formatTime24hMem().format(dateObj); + +const formatTime24hMem = memoizeOne( + () => + // en-GB to fix Chrome 24:59 to 0:59 https://stackoverflow.com/a/60898146 + new Intl.DateTimeFormat("en-GB", { + hour: "numeric", + minute: "2-digit", + hour12: false, + }) +); diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index bc7e1de564..ba759b197f 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -64,9 +64,12 @@ export const computeStateDisplayFromEntityAttributes = ( // fallback to default } } - return `${formatNumber(state, locale)}${ - attributes.unit_of_measurement ? " " + attributes.unit_of_measurement : "" - }`; + const unit = !attributes.unit_of_measurement + ? "" + : attributes.unit_of_measurement === "%" + ? "%" + : ` ${attributes.unit_of_measurement}`; + return `${formatNumber(state, locale)}${unit}`; } const domain = computeDomain(entityId); diff --git a/src/common/entity/get_states.ts b/src/common/entity/get_states.ts new file mode 100644 index 0000000000..7adddf87e1 --- /dev/null +++ b/src/common/entity/get_states.ts @@ -0,0 +1,277 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { computeStateDomain } from "./compute_state_domain"; +import { UNAVAILABLE_STATES } from "../../data/entity"; + +const FIXED_DOMAIN_STATES = { + alarm_control_panel: [ + "armed_away", + "armed_custom_bypass", + "armed_home", + "armed_night", + "armed_vacation", + "arming", + "disarmed", + "disarming", + "pending", + "triggered", + ], + automation: ["on", "off"], + binary_sensor: ["on", "off"], + button: [], + calendar: ["on", "off"], + camera: ["idle", "recording", "streaming"], + cover: ["closed", "closing", "open", "opening"], + device_tracker: ["home", "not_home"], + fan: ["on", "off"], + humidifier: ["on", "off"], + input_boolean: ["on", "off"], + input_button: [], + light: ["on", "off"], + lock: ["jammed", "locked", "locking", "unlocked", "unlocking"], + media_player: ["idle", "off", "paused", "playing", "standby"], + person: ["home", "not_home"], + remote: ["on", "off"], + scene: [], + schedule: ["on", "off"], + script: ["on", "off"], + siren: ["on", "off"], + sun: ["above_horizon", "below_horizon"], + switch: ["on", "off"], + update: ["on", "off"], + vacuum: ["cleaning", "docked", "error", "idle", "paused", "returning"], + weather: [ + "clear-night", + "cloudy", + "exceptional", + "fog", + "hail", + "lightning-rainy", + "lightning", + "partlycloudy", + "pouring", + "rainy", + "snowy-rainy", + "snowy", + "sunny", + "windy-variant", + "windy", + ], +}; + +const FIXED_DOMAIN_ATTRIBUTE_STATES = { + alarm_control_panel: { + code_format: ["number", "text"], + }, + binary_sensor: { + device_class: [ + "battery", + "battery_charging", + "co", + "cold", + "connectivity", + "door", + "garage_door", + "gas", + "heat", + "light", + "lock", + "moisture", + "motion", + "moving", + "occupancy", + "opening", + "plug", + "power", + "presence", + "problem", + "running", + "safety", + "smoke", + "sound", + "tamper", + "update", + "vibration", + "window", + ], + }, + button: { + device_class: ["restart", "update"], + }, + camera: { + frontend_stream_type: ["hls", "web_rtc"], + }, + climate: { + hvac_action: ["off", "idle", "heating", "cooling", "drying", "fan"], + }, + cover: { + device_class: [ + "awning", + "blind", + "curtain", + "damper", + "door", + "garage", + "gate", + "shade", + "shutter", + "window", + ], + }, + humidifier: { + device_class: ["humidifier", "dehumidifier"], + }, + media_player: { + device_class: ["tv", "speaker", "receiver"], + media_content_type: [ + "app", + "channel", + "episode", + "game", + "image", + "movie", + "music", + "playlist", + "tvshow", + "url", + "video", + ], + }, + number: { + device_class: ["temperature"], + }, + sensor: { + device_class: [ + "apparent_power", + "aqi", + "battery", + "carbon_dioxide", + "carbon_monoxide", + "current", + "date", + "duration", + "energy", + "frequency", + "gas", + "humidity", + "illuminance", + "monetary", + "nitrogen_dioxide", + "nitrogen_monoxide", + "nitrous_oxide", + "ozone", + "pm1", + "pm10", + "pm25", + "power_factor", + "power", + "pressure", + "reactive_power", + "signal_strength", + "sulphur_dioxide", + "temperature", + "timestamp", + "volatile_organic_compounds", + "voltage", + ], + state_class: ["measurement", "total", "total_increasing"], + }, + switch: { + device_class: ["outlet", "switch"], + }, + update: { + device_class: ["firmware"], + }, + water_heater: { + away_mode: ["on", "off"], + }, +}; + +export const getStates = ( + state: HassEntity, + attribute: string | undefined = undefined +): string[] => { + const domain = computeStateDomain(state); + const result: string[] = []; + + if (!attribute && domain in FIXED_DOMAIN_STATES) { + result.push(...FIXED_DOMAIN_STATES[domain]); + } else if ( + attribute && + domain in FIXED_DOMAIN_ATTRIBUTE_STATES && + attribute in FIXED_DOMAIN_ATTRIBUTE_STATES[domain] + ) { + result.push(...FIXED_DOMAIN_ATTRIBUTE_STATES[domain][attribute]); + } + + // Dynamic values based on the entities + switch (domain) { + case "climate": + if (!attribute) { + result.push(...state.attributes.hvac_modes); + } else if (attribute === "fan_mode") { + result.push(...state.attributes.fan_modes); + } else if (attribute === "preset_mode") { + result.push(...state.attributes.preset_modes); + } else if (attribute === "swing_mode") { + result.push(...state.attributes.swing_modes); + } + break; + case "device_tracker": + case "person": + if (!attribute) { + result.push("home", "not_home"); + } + break; + case "fan": + if (attribute === "preset_mode") { + result.push(...state.attributes.preset_modes); + } + break; + case "humidifier": + if (attribute === "mode") { + result.push(...state.attributes.available_modes); + } + break; + case "input_select": + case "select": + if (!attribute) { + result.push(...state.attributes.options); + } + break; + case "light": + if (attribute === "effect") { + result.push(...state.attributes.effect_list); + } else if (attribute === "color_mode") { + result.push(...state.attributes.color_modes); + } + break; + case "media_player": + if (attribute === "sound_mode") { + result.push(...state.attributes.sound_mode_list); + } else if (attribute === "source") { + result.push(...state.attributes.source_list); + } + break; + case "remote": + if (attribute === "current_activity") { + result.push(...state.attributes.activity_list); + } + break; + case "vacuum": + if (attribute === "fan_speed") { + result.push(...state.attributes.fan_speed_list); + } + break; + case "water_heater": + if (!attribute || attribute === "operation_mode") { + result.push(...state.attributes.operation_list); + } + break; + } + + if (!attribute) { + // All entities can have unavailable states + result.push(...UNAVAILABLE_STATES); + } + return [...new Set(result)]; +}; diff --git a/src/common/translations/localize.ts b/src/common/translations/localize.ts index 06a16904f1..5d6224d060 100644 --- a/src/common/translations/localize.ts +++ b/src/common/translations/localize.ts @@ -9,18 +9,47 @@ import { getLocalLanguage } from "../../util/common-translation"; // Exclude some patterns from key type checking for now // These are intended to be removed as errors are fixed // Fixing component category will require tighter definition of types from backend and/or web socket -type LocalizeKeyExceptions = - | `${string}` +export type LocalizeKeys = + | FlattenObjectKeys> | `panel.${string}` | `state.${string}` | `state_attributes.${string}` | `state_badge.${string}` - | `ui.${string}` - | `${keyof TranslationDict["supervisor"]}.${string}` + | `ui.card.alarm_control_panel.${string}` + | `ui.card.weather.attributes.${string}` + | `ui.card.weather.cardinal_direction.${string}` + | `ui.components.logbook.${string}` + | `ui.components.selectors.file.${string}` + | `ui.dialogs.entity_registry.editor.${string}` + | `ui.dialogs.more_info_control.vacuum.${string}` + | `ui.dialogs.options_flow.loading.${string}` + | `ui.dialogs.quick-bar.commands.${string}` + | `ui.dialogs.repair_flow.loading.${string}` + | `ui.dialogs.unhealthy.reason.${string}` + | `ui.dialogs.unsupported.reason.${string}` + | `ui.panel.config.${string}.${"caption" | "description"}` + | `ui.panel.config.automation.${string}` + | `ui.panel.config.dashboard.${string}` + | `ui.panel.config.devices.${string}` + | `ui.panel.config.energy.${string}` + | `ui.panel.config.helpers.${string}` + | `ui.panel.config.info.${string}` + | `ui.panel.config.integrations.${string}` + | `ui.panel.config.logs.${string}` + | `ui.panel.config.lovelace.${string}` + | `ui.panel.config.network.${string}` + | `ui.panel.config.scene.${string}` + | `ui.panel.config.url.${string}` + | `ui.panel.config.zha.${string}` + | `ui.panel.config.zwave_js.${string}` + | `ui.panel.developer-tools.tabs.${string}` + | `ui.panel.lovelace.card.${string}` + | `ui.panel.lovelace.editor.${string}` + | `ui.panel.page-authorize.form.${string}` | `component.${string}`; // Tweaked from https://www.raygesualdo.com/posts/flattening-object-keys-with-typescript-types -type FlattenObjectKeys< +export type FlattenObjectKeys< T extends Record, Key extends keyof T = keyof T > = Key extends string @@ -29,10 +58,8 @@ type FlattenObjectKeys< : `${Key}` : never; -export type LocalizeFunc< - Dict extends Record = TranslationDict -> = ( - key: FlattenObjectKeys | LocalizeKeyExceptions, +export type LocalizeFunc = ( + key: Keys, ...args: any[] ) => string; @@ -94,14 +121,12 @@ export const polyfillsLoaded = * } */ -export const computeLocalize = async < - Dict extends Record = TranslationDict ->( +export const computeLocalize = async ( cache: any, language: string, resources: Resources, formats?: FormatsType -): Promise> => { +): Promise> => { if (polyfillsLoaded) { await polyfillsLoaded; } diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index 9f793a6977..ece8671a8c 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -15,13 +15,13 @@ import { import { customElement, property, state } from "lit/decorators"; import { getGraphColorByIndex } from "../../common/color/colors"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; -import { computeStateName } from "../../common/entity/compute_state_name"; import { formatNumber, numberFormatToLocale, } from "../../common/number/format_number"; import { getStatisticIds, + getStatisticLabel, Statistics, statisticsHaveType, StatisticsMetaData, @@ -233,24 +233,18 @@ class StatisticsChart extends LitElement { const names = this.names || {}; statisticsData.forEach((stats) => { const firstStat = stats[0]; - let name = names[firstStat.statistic_id]; - if (!name) { - const entityState = this.hass.states[firstStat.statistic_id]; - if (entityState) { - name = computeStateName(entityState); - } else { - name = firstStat.statistic_id; - } - } - const meta = this.statisticIds!.find( (stat) => stat.statistic_id === firstStat.statistic_id ); + let name = names[firstStat.statistic_id]; + if (!name) { + name = getStatisticLabel(this.hass, firstStat.statistic_id, meta); + } if (!this.unit) { if (unit === undefined) { - unit = meta?.unit_of_measurement; - } else if (unit !== meta?.unit_of_measurement) { + unit = meta?.display_unit_of_measurement; + } else if (unit !== meta?.display_unit_of_measurement) { unit = null; } } diff --git a/src/components/date-range-picker.ts b/src/components/date-range-picker.ts index e6a5c6479f..7224e047f1 100644 --- a/src/components/date-range-picker.ts +++ b/src/components/date-range-picker.ts @@ -221,6 +221,10 @@ class DateRangePickerElement extends WrappedElement { .calendar-table { padding: 0 !important; } + .daterangepicker.ltr { + direction: ltr; + text-align: left; + } `; const shadowRoot = this.shadowRoot!; shadowRoot.appendChild(style); diff --git a/src/components/device/ha-area-devices-picker.ts b/src/components/device/ha-area-devices-picker.ts index 6ce007e441..169a49f518 100644 --- a/src/components/device/ha-area-devices-picker.ts +++ b/src/components/device/ha-area-devices-picker.ts @@ -1,7 +1,7 @@ import "@material/mwc-button/mwc-button"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { html, LitElement, PropertyValues, TemplateResult } from "lit"; -import { ComboBoxLitRenderer } from "lit-vaadin-helpers"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; diff --git a/src/components/device/ha-device-automation-picker.ts b/src/components/device/ha-device-automation-picker.ts index 9e12fe6e8e..d14833ca9e 100644 --- a/src/components/device/ha-device-automation-picker.ts +++ b/src/components/device/ha-device-automation-picker.ts @@ -172,8 +172,7 @@ export abstract class HaDeviceAutomationPicker< static get styles(): CSSResultGroup { return css` ha-select { - width: 100%; - margin-top: 4px; + display: block; } `; } diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index e2abdac836..b387be1115 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -1,7 +1,7 @@ import "@material/mwc-list/mwc-list-item"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { html, LitElement, PropertyValues, TemplateResult } from "lit"; -import { ComboBoxLitRenderer } from "lit-vaadin-helpers"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; diff --git a/src/components/entity/ha-entity-attribute-picker.ts b/src/components/entity/ha-entity-attribute-picker.ts index 3a73ad3fcb..6fb98f55b9 100644 --- a/src/components/entity/ha-entity-attribute-picker.ts +++ b/src/components/entity/ha-entity-attribute-picker.ts @@ -15,6 +15,14 @@ class HaEntityAttributePicker extends LitElement { @property() public entityId?: string; + /** + * List of attributes to be hidden. + * @type {Array} + * @attr hide-attributes + */ + @property({ type: Array, attribute: "hide-attributes" }) + public hideAttributes?: string[]; + @property({ type: Boolean }) public autofocus = false; @property({ type: Boolean }) public disabled = false; @@ -42,10 +50,12 @@ class HaEntityAttributePicker extends LitElement { if (changedProps.has("_opened") && this._opened) { const state = this.entityId ? this.hass.states[this.entityId] : undefined; (this._comboBox as any).items = state - ? Object.keys(state.attributes).map((key) => ({ - value: key, - label: formatAttributeName(key), - })) + ? Object.keys(state.attributes) + .filter((key) => !this.hideAttributes?.includes(key)) + .map((key) => ({ + value: key, + label: formatAttributeName(key), + })) : []; } } @@ -58,7 +68,7 @@ class HaEntityAttributePicker extends LitElement { return html` boolean; + +@customElement("ha-entity-state-picker") +class HaEntityStatePicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public entityId?: string; + + @property() public attribute?: string; + + @property({ type: Boolean }) public autofocus = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @property({ type: Boolean, attribute: "allow-custom-value" }) + public allowCustomValue; + + @property() public label?: string; + + @property() public value?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) private _opened = false; + + @query("ha-combo-box", true) private _comboBox!: HaComboBox; + + protected shouldUpdate(changedProps: PropertyValues) { + return !(!changedProps.has("_opened") && this._opened); + } + + protected updated(changedProps: PropertyValues) { + if (changedProps.has("_opened") && this._opened) { + const state = this.entityId ? this.hass.states[this.entityId] : undefined; + (this._comboBox as any).items = + this.entityId && state + ? getStates(state, this.attribute).map((key) => ({ + value: key, + label: !this.attribute + ? computeStateDisplay( + this.hass.localize, + state, + this.hass.locale, + key + ) + : key, + })) + : []; + } + } + + protected render(): TemplateResult { + if (!this.hass) { + return html``; + } + + return html` + + + `; + } + + private _openedChanged(ev: PolymerChangedEvent) { + this._opened = ev.detail.value; + } + + private _valueChanged(ev: PolymerChangedEvent) { + this.value = ev.detail.value; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-entity-state-picker": HaEntityStatePicker; + } +} diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index 76965ecee5..a2533c7bb9 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -1,6 +1,6 @@ import { HassEntity } from "home-assistant-js-websocket"; import { html, LitElement, PropertyValues, TemplateResult } from "lit"; -import { ComboBoxLitRenderer } from "lit-vaadin-helpers"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; @@ -31,12 +31,23 @@ export class HaStatisticPicker extends LitElement { @property({ type: Boolean }) public disabled?: boolean; /** - * Show only statistics with these unit of measuments. + * Show only statistics natively stored with these units of measurements. * @type {Array} - * @attr include-unit-of-measurement + * @attr include-statistics-unit-of-measurement */ - @property({ type: Array, attribute: "include-unit-of-measurement" }) - public includeUnitOfMeasurement?: string[]; + @property({ + type: Array, + attribute: "include-statistics-unit-of-measurement", + }) + public includeStatisticsUnitOfMeasurement?: string[]; + + /** + * Show only statistics displayed with these units of measurements. + * @type {Array} + * @attr include-display-unit-of-measurement + */ + @property({ type: Array, attribute: "include-display-unit-of-measurement" }) + public includeDisplayUnitOfMeasurement?: string[]; /** * Show only statistics with these device classes. @@ -86,7 +97,8 @@ export class HaStatisticPicker extends LitElement { private _getStatistics = memoizeOne( ( statisticIds: StatisticsMetaData[], - includeUnitOfMeasurement?: string[], + includeStatisticsUnitOfMeasurement?: string[], + includeDisplayUnitOfMeasurement?: string[], includeDeviceClasses?: string[], entitiesOnly?: boolean ): Array<{ id: string; name: string; state?: HassEntity }> => { @@ -101,9 +113,18 @@ export class HaStatisticPicker extends LitElement { ]; } - if (includeUnitOfMeasurement) { + if (includeStatisticsUnitOfMeasurement) { statisticIds = statisticIds.filter((meta) => - includeUnitOfMeasurement.includes(meta.unit_of_measurement) + includeStatisticsUnitOfMeasurement.includes( + meta.statistics_unit_of_measurement + ) + ); + } + if (includeDisplayUnitOfMeasurement) { + statisticIds = statisticIds.filter((meta) => + includeDisplayUnitOfMeasurement.includes( + meta.display_unit_of_measurement + ) ); } @@ -184,7 +205,8 @@ export class HaStatisticPicker extends LitElement { if (this.hasUpdated) { (this.comboBox as any).items = this._getStatistics( this.statisticIds!, - this.includeUnitOfMeasurement, + this.includeStatisticsUnitOfMeasurement, + this.includeDisplayUnitOfMeasurement, this.includeDeviceClasses, this.entitiesOnly ); @@ -192,7 +214,8 @@ export class HaStatisticPicker extends LitElement { this.updateComplete.then(() => { (this.comboBox as any).items = this._getStatistics( this.statisticIds!, - this.includeUnitOfMeasurement, + this.includeStatisticsUnitOfMeasurement, + this.includeDisplayUnitOfMeasurement, this.includeDeviceClasses, this.entitiesOnly ); diff --git a/src/components/ha-addon-picker.ts b/src/components/ha-addon-picker.ts index 4c3363a610..cd8b7c4bf5 100644 --- a/src/components/ha-addon-picker.ts +++ b/src/components/ha-addon-picker.ts @@ -1,5 +1,5 @@ import { html, LitElement, TemplateResult } from "lit"; -import { ComboBoxLitRenderer } from "lit-vaadin-helpers"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { customElement, property, query, state } from "lit/decorators"; import { isComponentLoaded } from "../common/config/is_component_loaded"; import { fireEvent } from "../common/dom/fire_event"; diff --git a/src/components/ha-alert.ts b/src/components/ha-alert.ts index aa6848a48b..d49c9959ac 100644 --- a/src/components/ha-alert.ts +++ b/src/components/ha-alert.ts @@ -83,7 +83,6 @@ class HaAlert extends LitElement { position: relative; padding: 8px; display: flex; - margin: 4px 0; } .issue-type.rtl { flex-direction: row-reverse; diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 8225a73eee..6a56868bd2 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -1,6 +1,6 @@ import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { html, LitElement, PropertyValues, TemplateResult } from "lit"; -import { ComboBoxLitRenderer } from "lit-vaadin-helpers"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; diff --git a/src/components/ha-base-time-input.ts b/src/components/ha-base-time-input.ts index f1efcf9df3..038dda86bb 100644 --- a/src/components/ha-base-time-input.ts +++ b/src/components/ha-base-time-input.ts @@ -1,10 +1,11 @@ import "@material/mwc-list/mwc-list-item"; import { css, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../common/dom/fire_event"; import { stopPropagation } from "../common/dom/stop_propagation"; import "./ha-select"; -import "./ha-textfield"; +import { HaTextField } from "./ha-textfield"; import "./ha-input-helper-text"; export interface TimeChangedEvent { @@ -36,7 +37,7 @@ export class HaBaseTimeInput extends LitElement { /** * determines if inputs are required */ - @property({ type: Boolean }) public required?: boolean; + @property({ type: Boolean }) public required = false; /** * 12 or 24 hr format @@ -123,11 +124,6 @@ export class HaBaseTimeInput extends LitElement { */ @property() amPm: "AM" | "PM" = "AM"; - /** - * Formatted time string - */ - @property() value?: string; - protected render(): TemplateResult { return html` ${this.label @@ -140,11 +136,11 @@ export class HaBaseTimeInput extends LitElement { id="day" type="number" inputmode="numeric" - .value=${this.days} + .value=${this.days.toFixed()} .label=${this.dayLabel} name="days" @input=${this._valueChanged} - @focus=${this._onFocus} + @focusin=${this._onFocus} no-spinner .required=${this.required} .autoValidate=${this.autoValidate} @@ -161,16 +157,16 @@ export class HaBaseTimeInput extends LitElement { id="hour" type="number" inputmode="numeric" - .value=${this.hours} + .value=${this.hours.toFixed()} .label=${this.hourLabel} name="hours" @input=${this._valueChanged} - @focus=${this._onFocus} + @focusin=${this._onFocus} no-spinner .required=${this.required} .autoValidate=${this.autoValidate} maxlength="2" - .max=${this._hourMax} + max=${ifDefined(this._hourMax)} min="0" .disabled=${this.disabled} suffix=":" @@ -184,7 +180,7 @@ export class HaBaseTimeInput extends LitElement { .value=${this._formatValue(this.minutes)} .label=${this.minLabel} @input=${this._valueChanged} - @focus=${this._onFocus} + @focusin=${this._onFocus} name="minutes" no-spinner .required=${this.required} @@ -205,7 +201,7 @@ export class HaBaseTimeInput extends LitElement { .value=${this._formatValue(this.seconds)} .label=${this.secLabel} @input=${this._valueChanged} - @focus=${this._onFocus} + @focusin=${this._onFocus} name="seconds" no-spinner .required=${this.required} @@ -226,7 +222,7 @@ export class HaBaseTimeInput extends LitElement { .value=${this._formatValue(this.milliseconds, 3)} .label=${this.millisecLabel} @input=${this._valueChanged} - @focus=${this._onFocus} + @focusin=${this._onFocus} name="milliseconds" no-spinner .required=${this.required} @@ -260,9 +256,10 @@ export class HaBaseTimeInput extends LitElement { `; } - private _valueChanged(ev) { - this[ev.target.name] = - ev.target.name === "amPm" ? ev.target.value : Number(ev.target.value); + private _valueChanged(ev: InputEvent) { + const textField = ev.currentTarget as HaTextField; + this[textField.name] = + textField.name === "amPm" ? textField.value : Number(textField.value); const value: TimeChangedEvent = { hours: this.hours, minutes: this.minutes, @@ -277,8 +274,8 @@ export class HaBaseTimeInput extends LitElement { }); } - private _onFocus(ev) { - ev.target.select(); + private _onFocus(ev: FocusEvent) { + (ev.currentTarget as HaTextField).select(); } /** @@ -293,7 +290,7 @@ export class HaBaseTimeInput extends LitElement { */ private get _hourMax() { if (this.noHoursLimit) { - return null; + return undefined; } if (this.format === 12) { return 12; diff --git a/src/components/ha-blueprint-picker.ts b/src/components/ha-blueprint-picker.ts index b018d4c768..58a0f03f4a 100644 --- a/src/components/ha-blueprint-picker.ts +++ b/src/components/ha-blueprint-picker.ts @@ -5,7 +5,12 @@ 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 { Blueprint, Blueprints, fetchBlueprints } from "../data/blueprint"; +import { + Blueprint, + BlueprintDomain, + Blueprints, + fetchBlueprints, +} from "../data/blueprint"; import { HomeAssistant } from "../types"; import "./ha-select"; @@ -17,7 +22,7 @@ class HaBluePrintPicker extends LitElement { @property() public value = ""; - @property() public domain = "automation"; + @property() public domain: BlueprintDomain = "automation"; @property() public blueprints?: Blueprints; diff --git a/src/components/ha-combo-box.ts b/src/components/ha-combo-box.ts index f900c2fa9f..a23ef9ee2e 100644 --- a/src/components/ha-combo-box.ts +++ b/src/components/ha-combo-box.ts @@ -9,8 +9,9 @@ import type { } from "@vaadin/combo-box/vaadin-combo-box-light"; import { registerStyles } from "@vaadin/vaadin-themable-mixin/register-styles"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { ComboBoxLitRenderer, comboBoxRenderer } from "lit-vaadin-helpers"; +import { ComboBoxLitRenderer, comboBoxRenderer } from "@vaadin/combo-box/lit"; import { customElement, property, query } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../common/dom/fire_event"; import { HomeAssistant } from "../types"; import "./ha-icon-button"; @@ -72,31 +73,31 @@ export class HaComboBox extends LitElement { @property({ attribute: "error-message" }) public errorMessage?: string; - @property({ type: Boolean }) public invalid?: boolean; + @property({ type: Boolean }) public invalid = false; - @property({ type: Boolean }) public icon?: boolean; + @property({ type: Boolean }) public icon = false; - @property() public items?: any[]; + @property({ attribute: false }) public items?: any[]; - @property() public filteredItems?: any[]; + @property({ attribute: false }) public filteredItems?: any[]; @property({ attribute: "allow-custom-value", type: Boolean }) - public allowCustomValue?: boolean; + public allowCustomValue = false; - @property({ attribute: "item-value-path" }) public itemValuePath?: string; + @property({ attribute: "item-value-path" }) public itemValuePath = "value"; - @property({ attribute: "item-label-path" }) public itemLabelPath?: string; + @property({ attribute: "item-label-path" }) public itemLabelPath = "label"; @property({ attribute: "item-id-path" }) public itemIdPath?: string; @property() public renderer?: ComboBoxLitRenderer; - @property({ type: Boolean }) public disabled?: boolean; + @property({ type: Boolean }) public disabled = false; - @property({ type: Boolean }) public required?: boolean; + @property({ type: Boolean }) public required = false; @property({ type: Boolean, reflect: true, attribute: "opened" }) - private _opened?: boolean; + public opened?: boolean; @query("vaadin-combo-box-light", true) private _comboBox!: ComboBoxLight; @@ -149,37 +150,45 @@ export class HaComboBox extends LitElement { attr-for-value="value" >
`} + .suffix=${html`
`} .icon=${this.icon} .invalid=${this.invalid} - .helper=${this.helper} + helper=${ifDefined(this.helper)} helperPersistent > ${this.value ? html`` : ""} @@ -199,7 +208,7 @@ export class HaComboBox extends LitElement { } private _toggleOpen(ev: Event) { - if (this._opened) { + if (this.opened) { this._comboBox?.close(); ev.stopPropagation(); } else { @@ -211,7 +220,7 @@ export class HaComboBox extends LitElement { const opened = ev.detail.value; // delay this so we can handle click event before setting _opened setTimeout(() => { - this._opened = opened; + this.opened = opened; }, 0); // @ts-ignore fireEvent(this, ev.type, ev.detail); diff --git a/src/components/ha-config-entry-picker.ts b/src/components/ha-config-entry-picker.ts new file mode 100644 index 0000000000..0f98d18d49 --- /dev/null +++ b/src/components/ha-config-entry-picker.ts @@ -0,0 +1,156 @@ +import "@material/mwc-list/mwc-list-item"; +import { html, LitElement, TemplateResult } from "lit"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { PolymerChangedEvent } from "../polymer-types"; +import { HomeAssistant } from "../types"; +import type { HaComboBox } from "./ha-combo-box"; +import { ConfigEntry, getConfigEntries } from "../data/config_entries"; +import { domainToName } from "../data/integration"; +import { caseInsensitiveStringCompare } from "../common/string/compare"; +import { brandsUrl } from "../util/brands-url"; +import "./ha-combo-box"; + +export interface ConfigEntryExtended extends ConfigEntry { + localized_domain_name?: string; +} + +@customElement("ha-config-entry-picker") +class HaConfigEntryPicker extends LitElement { + public hass!: HomeAssistant; + + @property() public integration?: string; + + @property() public label?: string; + + @property() public value = ""; + + @property() public helper?: string; + + @state() private _configEntries?: ConfigEntryExtended[]; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @query("ha-combo-box") private _comboBox!: HaComboBox; + + public open() { + this._comboBox?.open(); + } + + public focus() { + this._comboBox?.focus(); + } + + protected firstUpdated() { + this._getConfigEntries(); + } + + private _rowRenderer: ComboBoxLitRenderer = ( + item + ) => html` + ${item.title || + this.hass.localize( + "ui.panel.config.integrations.config_entry.unnamed_entry" + )} + ${item.localized_domain_name} + + `; + + protected render(): TemplateResult { + if (!this._configEntries) { + return html``; + } + return html` + + `; + } + + private _onImageLoad(ev) { + ev.target.style.visibility = "initial"; + } + + private _onImageError(ev) { + ev.target.style.visibility = "hidden"; + } + + private async _getConfigEntries() { + getConfigEntries(this.hass, { + type: "integration", + domain: this.integration, + }).then((configEntries) => { + this._configEntries = configEntries + .map( + (entry: ConfigEntry): ConfigEntryExtended => ({ + ...entry, + localized_domain_name: domainToName( + this.hass.localize, + entry.domain + ), + }) + ) + .sort((conf1, conf2) => + caseInsensitiveStringCompare( + conf1.localized_domain_name + conf1.title, + conf2.localized_domain_name + conf2.title + ) + ); + }); + } + + private get _value() { + return this.value || ""; + } + + private _valueChanged(ev: PolymerChangedEvent) { + ev.stopPropagation(); + const newValue = ev.detail.value; + + if (newValue !== this._value) { + this._setValue(newValue); + } + } + + private _setValue(value: string) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + }, 0); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-entry-picker": HaConfigEntryPicker; + } +} diff --git a/src/components/ha-dialog.ts b/src/components/ha-dialog.ts index 8f28e4cf7d..b5b27e911f 100644 --- a/src/components/ha-dialog.ts +++ b/src/components/ha-dialog.ts @@ -80,6 +80,7 @@ export class HaDialog extends DialogBase { .mdc-dialog .mdc-dialog__surface { position: var(--dialog-surface-position, relative); top: var(--dialog-surface-top); + margin-top: var(--dialog-surface-margin-top); min-height: var(--mdc-dialog-min-height, auto); border-radius: var(--ha-dialog-border-radius, 28px); } diff --git a/src/components/ha-duration-input.ts b/src/components/ha-duration-input.ts index 408d1e85c7..7d35fe160f 100644 --- a/src/components/ha-duration-input.ts +++ b/src/components/ha-duration-input.ts @@ -14,17 +14,17 @@ export interface HaDurationData { @customElement("ha-duration-input") class HaDurationInput extends LitElement { - @property({ attribute: false }) public data!: HaDurationData; + @property({ attribute: false }) public data?: HaDurationData; @property() public label?: string; @property() public helper?: string; - @property({ type: Boolean }) public required?: boolean; + @property({ type: Boolean }) public required = false; - @property({ type: Boolean }) public enableMillisecond?: boolean; + @property({ type: Boolean }) public enableMillisecond = false; - @property({ type: Boolean }) public enableDay?: boolean; + @property({ type: Boolean }) public enableDay = false; @property({ type: Boolean }) public disabled = false; diff --git a/src/components/ha-expansion-panel.ts b/src/components/ha-expansion-panel.ts index a4f3817508..8c0fd25539 100644 --- a/src/components/ha-expansion-panel.ts +++ b/src/components/ha-expansion-panel.ts @@ -14,11 +14,13 @@ import { nextRender } from "../common/util/render-status"; import "./ha-svg-icon"; @customElement("ha-expansion-panel") -class HaExpansionPanel extends LitElement { +export class HaExpansionPanel extends LitElement { @property({ type: Boolean, reflect: true }) expanded = false; @property({ type: Boolean, reflect: true }) outlined = false; + @property({ type: Boolean, reflect: true }) leftChevron = false; + @property() header?: string; @property() secondary?: string; @@ -29,23 +31,42 @@ class HaExpansionPanel extends LitElement { protected render(): TemplateResult { return html` -
- - ${this.header} - ${this.secondary} - - +
+
+ ${this.leftChevron + ? html` + + ` + : ""} + +
+ ${this.header} + ${this.secondary} +
+
+ ${!this.leftChevron + ? html` + + ` + : ""} +
+
{ + // Verify we're still expanded + if (this.expanded) { + this._container.style.overflow = "initial"; + } + }, 300); } } private _handleTransitionEnd() { this._container.style.removeProperty("height"); + this._container.style.overflow = this.expanded ? "initial" : "hidden"; this._showContent = this.expanded; } private async _toggleContainer(ev): Promise { + if (ev.defaultPrevented) { + return; + } if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") { return; } ev.preventDefault(); const newExpanded = !this.expanded; fireEvent(this, "expanded-will-change", { expanded: newExpanded }); + this._container.style.overflow = "hidden"; if (newExpanded) { this._showContent = true; @@ -98,12 +131,28 @@ class HaExpansionPanel extends LitElement { fireEvent(this, "expanded-changed", { expanded: this.expanded }); } + private _focusChanged(ev) { + this.shadowRoot!.querySelector(".top")!.classList.toggle( + "focused", + ev.type === "focus" + ); + } + static get styles(): CSSResultGroup { return css` :host { display: block; } + .top { + display: flex; + align-items: center; + } + + .top.focused { + background: var(--input-fill-color); + } + :host([outlined]) { box-shadow: none; border-width: 1px; @@ -115,7 +164,17 @@ class HaExpansionPanel extends LitElement { border-radius: var(--ha-card-border-radius, 4px); } + .summary-icon { + margin-left: 8px; + } + + :host([leftchevron]) .summary-icon { + margin-left: 0; + margin-right: 8px; + } + #summary { + flex: 1; display: flex; padding: var(--expansion-panel-summary-padding, 0 8px); min-height: 48px; @@ -126,15 +185,8 @@ class HaExpansionPanel extends LitElement { outline: none; } - #summary:focus { - background: var(--input-fill-color); - } - .summary-icon { transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); - margin-left: auto; - margin-inline-start: auto; - margin-inline-end: initial; direction: var(--direction); } @@ -142,6 +194,11 @@ class HaExpansionPanel extends LitElement { transform: rotate(180deg); } + .header, + ::slotted([slot="header"]) { + flex: 1; + } + .container { padding: var(--expansion-panel-content-padding, 0 8px); overflow: hidden; @@ -153,10 +210,6 @@ class HaExpansionPanel extends LitElement { height: auto; } - .header { - display: block; - } - .secondary { display: block; color: var(--secondary-text-color); diff --git a/src/components/ha-form/compute-initial-ha-form-data.ts b/src/components/ha-form/compute-initial-ha-form-data.ts index 6d24bbe531..96c1ae58fb 100644 --- a/src/components/ha-form/compute-initial-ha-form-data.ts +++ b/src/components/ha-form/compute-initial-ha-form-data.ts @@ -50,6 +50,7 @@ export const computeInitialHaFormData = ( "text" in selector || "addon" in selector || "attribute" in selector || + "file" in selector || "icon" in selector || "theme" in selector ) { diff --git a/src/components/ha-form/ha-form-positive_time_period_dict.ts b/src/components/ha-form/ha-form-positive_time_period_dict.ts index a693ce2721..ea2d56b114 100644 --- a/src/components/ha-form/ha-form-positive_time_period_dict.ts +++ b/src/components/ha-form/ha-form-positive_time_period_dict.ts @@ -5,9 +5,9 @@ import { HaFormElement, HaFormTimeData, HaFormTimeSchema } from "./types"; @customElement("ha-form-positive_time_period_dict") export class HaFormTimePeriod extends LitElement implements HaFormElement { - @property() public schema!: HaFormTimeSchema; + @property({ attribute: false }) public schema!: HaFormTimeSchema; - @property() public data!: HaFormTimeData; + @property({ attribute: false }) public data!: HaFormTimeData; @property() public label!: string; @@ -25,7 +25,7 @@ export class HaFormTimePeriod extends LitElement implements HaFormElement { return html` diff --git a/src/components/ha-form/ha-form.ts b/src/components/ha-form/ha-form.ts index 579a7ab7d1..7f83b3220c 100644 --- a/src/components/ha-form/ha-form.ts +++ b/src/components/ha-form/ha-form.ts @@ -35,20 +35,20 @@ export class HaForm extends LitElement implements HaFormElement { @property({ attribute: false }) public data!: HaFormDataContainer; - @property({ attribute: false }) public schema!: HaFormSchema[]; + @property({ attribute: false }) public schema!: readonly HaFormSchema[]; @property() public error?: Record; @property({ type: Boolean }) public disabled = false; - @property() public computeError?: (schema: HaFormSchema, error) => string; + @property() public computeError?: (schema: any, error) => string; @property() public computeLabel?: ( - schema: HaFormSchema, - data?: HaFormDataContainer + schema: any, + data: HaFormDataContainer ) => string; - @property() public computeHelper?: (schema: HaFormSchema) => string; + @property() public computeHelper?: (schema: any) => string | undefined; public focus() { const root = this.shadowRoot?.querySelector(".root"); @@ -168,7 +168,7 @@ export class HaForm extends LitElement implements HaFormElement { return this.computeHelper ? this.computeHelper(schema) : ""; } - private _computeError(error, schema: HaFormSchema | HaFormSchema[]) { + private _computeError(error, schema: HaFormSchema | readonly HaFormSchema[]) { return this.computeError ? this.computeError(error, schema) : error; } diff --git a/src/components/ha-form/types.ts b/src/components/ha-form/types.ts index d8e380cca4..6f0f2fd8b4 100644 --- a/src/components/ha-form/types.ts +++ b/src/components/ha-form/types.ts @@ -31,7 +31,7 @@ export interface HaFormGridSchema extends HaFormBaseSchema { type: "grid"; name: ""; column_min_width?: string; - schema: HaFormSchema[]; + schema: readonly HaFormSchema[]; } export interface HaFormSelector extends HaFormBaseSchema { @@ -53,12 +53,15 @@ export interface HaFormIntegerSchema extends HaFormBaseSchema { export interface HaFormSelectSchema extends HaFormBaseSchema { type: "select"; - options: Array<[string, string]>; + options: ReadonlyArray; } export interface HaFormMultiSelectSchema extends HaFormBaseSchema { type: "multi_select"; - options: Record | string[] | Array<[string, string]>; + options: + | Record + | readonly string[] + | ReadonlyArray; } export interface HaFormFloatSchema extends HaFormBaseSchema { @@ -78,6 +81,12 @@ export interface HaFormTimeSchema extends HaFormBaseSchema { type: "positive_time_period_dict"; } +// Type utility to unionize a schema array by flattening any grid schemas +export type SchemaUnion< + SchemaArray extends readonly HaFormSchema[], + Schema = SchemaArray[number] +> = Schema extends HaFormGridSchema ? SchemaUnion : Schema; + export interface HaFormDataContainer { [key: string]: HaFormData; } @@ -100,7 +109,7 @@ export type HaFormMultiSelectData = string[]; export type HaFormTimeData = HaDurationData; export interface HaFormElement extends LitElement { - schema: HaFormSchema | HaFormSchema[]; + schema: HaFormSchema | readonly HaFormSchema[]; data?: HaFormDataContainer | HaFormData; label?: string; } diff --git a/src/components/ha-gauge.ts b/src/components/ha-gauge.ts index 1d60da1b3c..e37d80a0b1 100644 --- a/src/components/ha-gauge.ts +++ b/src/components/ha-gauge.ts @@ -132,7 +132,9 @@ export class Gauge extends LitElement { this._segment_label ? this._segment_label : this.valueText || formatNumber(this.value, this.locale) - } ${this._segment_label ? "" : this.label} + }${ + this._segment_label ? "" : this.label === "%" ? "%" : ` ${this.label}` + } `; } diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index 220c434dea..871dc3e477 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -1,5 +1,5 @@ import { css, html, LitElement, TemplateResult } from "lit"; -import { ComboBoxLitRenderer } from "lit-vaadin-helpers"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { customElement, property, query, state } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; import { customIcons } from "../data/custom_icons"; diff --git a/src/components/ha-navigation-list.ts b/src/components/ha-navigation-list.ts index f354e98001..cb8c99d8d2 100644 --- a/src/components/ha-navigation-list.ts +++ b/src/components/ha-navigation-list.ts @@ -1,11 +1,12 @@ -import "@material/mwc-list/mwc-list"; -import "@material/mwc-list/mwc-list-item"; +import { ActionDetail } from "@material/mwc-list/mwc-list"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; +import { navigate } from "../common/navigate"; import type { PageNavigation } from "../layouts/hass-tabs-subpage"; import type { HomeAssistant } from "../types"; -import "./ha-clickable-list-item"; import "./ha-icon-next"; +import "./ha-list-item"; import "./ha-svg-icon"; @customElement("ha-navigation-list") @@ -18,17 +19,22 @@ class HaNavigationList extends LitElement { @property({ type: Boolean }) public hasSecondary = false; + @property() public label?: string; + public render(): TemplateResult { return html` - + ${this.pages.map( (page) => html` -
` : ""} - + ` )} `; } - private _entryClicked(ev) { - ev.currentTarget.blur(); + private _handleListAction(ev: CustomEvent) { + const path = this.pages[ev.detail.index].path; + if (path.endsWith("#external-app-configuration")) { + this.hass.auth.external!.fireMessage({ type: "config_screen/show" }); + } else { + navigate(path); + } } static styles: CSSResultGroup = css` @@ -75,10 +86,9 @@ class HaNavigationList extends LitElement { .icon-background ha-svg-icon { color: #fff; } - ha-clickable-list-item { + ha-list-item { cursor: pointer; font-size: var(--navigation-list-item-title-font-size); - padding: var(--navigation-list-item-padding) 0; } `; } diff --git a/src/components/ha-related-items.ts b/src/components/ha-related-items.ts index 54baf0f25f..89974e6be8 100644 --- a/src/components/ha-related-items.ts +++ b/src/components/ha-related-items.ts @@ -326,6 +326,9 @@ export class HaRelatedItems extends SubscribeMixin(LitElement) { line-height: var(--paper-font-title_-_line-height); opacity: var(--dark-primary-opacity); } + h3:first-child { + margin-top: 0; + } `; } } diff --git a/src/components/ha-selector/ha-selector-attribute.ts b/src/components/ha-selector/ha-selector-attribute.ts index 4d00fa8f9c..da06508e2f 100644 --- a/src/components/ha-selector/ha-selector-attribute.ts +++ b/src/components/ha-selector/ha-selector-attribute.ts @@ -8,9 +8,9 @@ import "../entity/ha-entity-attribute-picker"; @customElement("ha-selector-attribute") export class HaSelectorAttribute extends SubscribeMixin(LitElement) { - @property() public hass!: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; - @property() public selector!: AttributeSelector; + @property({ attribute: false }) public selector!: AttributeSelector; @property() public value?: any; @@ -22,7 +22,7 @@ export class HaSelectorAttribute extends SubscribeMixin(LitElement) { @property({ type: Boolean }) public required = true; - @property() public context?: { + @property({ attribute: false }) public context?: { filter_entity?: string; }; @@ -32,6 +32,7 @@ export class HaSelectorAttribute extends SubscribeMixin(LitElement) { .hass=${this.hass} .entityId=${this.selector.attribute.entity_id || this.context?.filter_entity} + .hideAttributes=${this.selector.attribute.hide_attributes} .value=${this.value} .label=${this.label} .helper=${this.helper} diff --git a/src/components/ha-selector/ha-selector-color-temp.ts b/src/components/ha-selector/ha-selector-color-temp.ts index d309e4f5d3..7a99a61261 100644 --- a/src/components/ha-selector/ha-selector-color-temp.ts +++ b/src/components/ha-selector/ha-selector-color-temp.ts @@ -47,7 +47,7 @@ export class HaColorTempSelector extends LitElement { static styles = css` ha-labeled-slider { --ha-slider-background: -webkit-linear-gradient( - right, + var(--float-end), rgb(255, 160, 0) 0%, white 50%, rgb(166, 209, 255) 100% diff --git a/src/components/ha-selector/ha-selector-config-entry.ts b/src/components/ha-selector/ha-selector-config-entry.ts new file mode 100644 index 0000000000..85f6823276 --- /dev/null +++ b/src/components/ha-selector/ha-selector-config-entry.ts @@ -0,0 +1,47 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { ConfigEntrySelector } from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../ha-config-entry-picker"; + +@customElement("ha-selector-config_entry") +export class HaConfigEntrySelector extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public selector!: ConfigEntrySelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + return html``; + } + + static styles = css` + ha-config-entry-picker { + width: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-config_entry": HaConfigEntrySelector; + } +} diff --git a/src/components/ha-selector/ha-selector-datetime.ts b/src/components/ha-selector/ha-selector-datetime.ts index cdb15ccacc..327122b92a 100644 --- a/src/components/ha-selector/ha-selector-datetime.ts +++ b/src/components/ha-selector/ha-selector-datetime.ts @@ -11,9 +11,9 @@ import type { HaTimeInput } from "../ha-time-input"; @customElement("ha-selector-datetime") export class HaDateTimeSelector extends LitElement { - @property() public hass!: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; - @property() public selector!: DateTimeSelector; + @property({ attribute: false }) public selector!: DateTimeSelector; @property() public value?: string; diff --git a/src/components/ha-selector/ha-selector-duration.ts b/src/components/ha-selector/ha-selector-duration.ts index 61bde6fa21..a2ad44a5d5 100644 --- a/src/components/ha-selector/ha-selector-duration.ts +++ b/src/components/ha-selector/ha-selector-duration.ts @@ -2,15 +2,15 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import type { DurationSelector } from "../../data/selector"; import type { HomeAssistant } from "../../types"; -import "../ha-duration-input"; +import { HaDurationData } from "../ha-duration-input"; @customElement("ha-selector-duration") export class HaTimeDuration extends LitElement { - @property() public hass!: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; - @property() public selector!: DurationSelector; + @property({ attribute: false }) public selector!: DurationSelector; - @property() public value?: string; + @property({ attribute: false }) public value?: HaDurationData; @property() public label?: string; @@ -28,7 +28,7 @@ export class HaTimeDuration extends LitElement { .data=${this.value} .disabled=${this.disabled} .required=${this.required} - .enableDay=${this.selector.duration.enable_day} + ?enableDay=${this.selector.duration.enable_day} > `; } diff --git a/src/components/ha-selector/ha-selector-file.ts b/src/components/ha-selector/ha-selector-file.ts new file mode 100644 index 0000000000..589e43cf9d --- /dev/null +++ b/src/components/ha-selector/ha-selector-file.ts @@ -0,0 +1,98 @@ +import { mdiFile } from "@mdi/js"; +import { html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { removeFile, uploadFile } from "../../data/file_upload"; +import { FileSelector } from "../../data/selector"; +import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; +import { HomeAssistant } from "../../types"; +import "../ha-file-upload"; + +@customElement("ha-selector-file") +export class HaFileSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: FileSelector; + + @property() public value?: string; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + @state() private _filename?: { fileId: string; name: string }; + + @state() private _busy = false; + + protected render() { + return html` + + `; + } + + protected willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + if ( + changedProps.has("value") && + this._filename && + this.value !== this._filename.fileId + ) { + this._filename = undefined; + } + } + + private async _uploadFile(ev) { + this._busy = true; + + const file = ev.detail.files![0]; + + try { + const fileId = await uploadFile(this.hass, file); + this._filename = { fileId, name: file.name }; + fireEvent(this, "value-changed", { value: fileId }); + } catch (err: any) { + showAlertDialog(this, { + text: this.hass.localize("ui.components.selectors.file.upload_failed", { + reason: err.message || err, + }), + }); + } finally { + this._busy = false; + } + } + + private _removeFile = async () => { + this._busy = true; + try { + await removeFile(this.hass, this.value!); + } catch (err) { + // Not ideal if removal fails, but will be cleaned up later + } finally { + this._busy = false; + } + this._filename = undefined; + fireEvent(this, "value-changed", { value: "" }); + }; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-file": HaFileSelector; + } +} diff --git a/src/components/ha-selector/ha-selector-icon.ts b/src/components/ha-selector/ha-selector-icon.ts index a9e654ca4a..d4a3fcc98a 100644 --- a/src/components/ha-selector/ha-selector-icon.ts +++ b/src/components/ha-selector/ha-selector-icon.ts @@ -24,6 +24,7 @@ export class HaIconSelector extends LitElement { protected render() { return html` `}`; } - private _computeLabelCallback = (schema: HaFormSchema): string => + private _computeLabelCallback = ( + schema: SchemaUnion + ): string => this.hass.localize(`ui.components.selectors.media.${schema.name}`); private _entityChanged(ev: CustomEvent) { diff --git a/src/components/ha-selector/ha-selector-state.ts b/src/components/ha-selector/ha-selector-state.ts new file mode 100644 index 0000000000..b72b0349bd --- /dev/null +++ b/src/components/ha-selector/ha-selector-state.ts @@ -0,0 +1,52 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { StateSelector } from "../../data/selector"; +import { SubscribeMixin } from "../../mixins/subscribe-mixin"; +import { HomeAssistant } from "../../types"; +import "../entity/ha-entity-state-picker"; + +@customElement("ha-selector-state") +export class HaSelectorState extends SubscribeMixin(LitElement) { + @property() public hass!: HomeAssistant; + + @property() public selector!: StateSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + @property() public context?: { + filter_attribute?: string; + filter_entity?: string; + }; + + protected render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-state": HaSelectorState; + } +} diff --git a/src/components/ha-selector/ha-selector-target.ts b/src/components/ha-selector/ha-selector-target.ts index 70443a097a..606e8d1702 100644 --- a/src/components/ha-selector/ha-selector-target.ts +++ b/src/components/ha-selector/ha-selector-target.ts @@ -64,7 +64,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { super.updated(changedProperties); if ( changedProperties.has("selector") && - this.selector.target.device?.integration && + (this.selector.target.device?.integration || + this.selector.target.entity?.integration) && !this._entitySources ) { fetchEntitySourcesWithCache(this.hass).then((sources) => { diff --git a/src/components/ha-selector/ha-selector-template.ts b/src/components/ha-selector/ha-selector-template.ts index 0761473dd8..e571f4c2ba 100644 --- a/src/components/ha-selector/ha-selector-template.ts +++ b/src/components/ha-selector/ha-selector-template.ts @@ -1,4 +1,4 @@ -import { html, LitElement } from "lit"; +import { css, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import { HomeAssistant } from "../../types"; @@ -48,6 +48,14 @@ export class HaTemplateSelector extends LitElement { } fireEvent(this, "value-changed", { value }); } + + static get styles() { + return css` + p { + margin-top: 0; + } + `; + } } declare global { diff --git a/src/components/ha-selector/ha-selector-time.ts b/src/components/ha-selector/ha-selector-time.ts index 80c753ba03..40982435a2 100644 --- a/src/components/ha-selector/ha-selector-time.ts +++ b/src/components/ha-selector/ha-selector-time.ts @@ -6,9 +6,9 @@ import "../ha-time-input"; @customElement("ha-selector-time") export class HaTimeSelector extends LitElement { - @property() public hass!: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; - @property() public selector!: TimeSelector; + @property({ attribute: false }) public selector!: TimeSelector; @property() public value?: string; diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index e2b3fdbf97..40f22e1a13 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -9,14 +9,17 @@ import "./ha-selector-area"; import "./ha-selector-attribute"; import "./ha-selector-boolean"; import "./ha-selector-color-rgb"; +import "./ha-selector-config-entry"; import "./ha-selector-date"; import "./ha-selector-datetime"; import "./ha-selector-device"; import "./ha-selector-duration"; import "./ha-selector-entity"; +import "./ha-selector-file"; import "./ha-selector-number"; import "./ha-selector-object"; import "./ha-selector-select"; +import "./ha-selector-state"; import "./ha-selector-target"; import "./ha-selector-template"; import "./ha-selector-text"; diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 54cca6a765..a282f9b5f5 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -230,7 +230,9 @@ export class HaServiceControl extends LitElement { @value-changed=${this._serviceChanged} >
-

${serviceData?.description}

+ ${serviceData?.description + ? html`

${serviceData?.description}

` + : ""} ${this._manifest ? html` { - this._panelOrder = this._sortable.toArray(); - }, - }); + private async _loadSortableStyle() { + if (this.sortableStyleLoaded) return; + + const sortStylesImport = await import("../resources/ha-sortable-style"); + + const style = document.createElement("style"); + style.innerHTML = (sortStylesImport.sortableStyles as CSSResult).cssText; + this.shadowRoot!.appendChild(style); + + this.sortableStyleLoaded = true; + await this.updateComplete; + } + + private async _createSortable() { + const Sortable = await loadSortable(); + this._sortable = new Sortable( + this.shadowRoot!.getElementById("sortable")!, + { + animation: 150, + fallbackClass: "sortable-fallback", + dataIdAttr: "data-panel", + handle: "paper-icon-item", + onSort: async () => { + this._panelOrder = this._sortable!.toArray(); + }, + } + ); } private _deactivateEditMode() { diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 0243534269..0f6ee2f41e 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -258,7 +258,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { } private _renderChip( - type: string, + type: "area_id" | "device_id" | "entity_id", id: string, name: string, entityState?: HassEntity, diff --git a/src/components/ha-time-input.ts b/src/components/ha-time-input.ts index eadbc744ce..cba4a0a95a 100644 --- a/src/components/ha-time-input.ts +++ b/src/components/ha-time-input.ts @@ -43,7 +43,7 @@ export class HaTimeInput extends LitElement { .minutes=${Number(parts[1])} .seconds=${Number(parts[2])} .format=${useAMPM ? 12 : 24} - .amPm=${useAMPM && (numberHours >= 12 ? "PM" : "AM")} + .amPm=${useAMPM && numberHours >= 12 ? "PM" : "AM"} .disabled=${this.disabled} @value-changed=${this._timeChanged} .enableSecond=${this.enableSecond} diff --git a/src/components/ha-water_heater-control.js b/src/components/ha-water_heater-control.js index f92034266e..a2521e5d8a 100644 --- a/src/components/ha-water_heater-control.js +++ b/src/components/ha-water_heater-control.js @@ -26,6 +26,7 @@ class HaWaterHeaterControl extends EventsMixin(PolymerElement) { #target_temperature { @apply --layout-self-center; font-size: 200%; + direction: ltr; } .control-buttons { font-size: 200%; diff --git a/src/components/ha-water_heater-state.js b/src/components/ha-water_heater-state.js index c9bd1147ac..4fb1330fcb 100644 --- a/src/components/ha-water_heater-state.js +++ b/src/components/ha-water_heater-state.js @@ -31,11 +31,16 @@ class HaWaterHeaterState extends LocalizeMixin(PolymerElement) { font-weight: bold; text-transform: capitalize; } + + .label { + direction: ltr; + display: inline-block; + }
- [[_localizeState(stateObj)]] - [[computeTarget(hass, stateObj)]] + [[_localizeState(stateObj)]] + [[computeTarget(hass, stateObj)]]