From a839494a1e3f54ef50a5ae705f5825fd7489462a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 7 Oct 2021 12:21:35 -0700 Subject: [PATCH] Use MWC components for ha-form (#10120) --- gallery/src/demos/demo-ha-form.ts | 224 ++++++++++++++--- .../addon-view/config/hassio-addon-config.ts | 2 +- package.json | 3 + src/auth/ha-auth-flow.ts | 102 ++++---- src/auth/ha-authorize.ts | 4 + src/auth/ha-password-manager-polyfill.ts | 4 +- src/components/ha-duration-input.ts | 2 - .../ha-form/compute-initial-ha-form-data.ts | 37 +++ src/components/ha-form/ha-form-boolean.ts | 31 +-- src/components/ha-form/ha-form-constant.ts | 19 +- src/components/ha-form/ha-form-float.ts | 61 +++-- src/components/ha-form/ha-form-integer.ts | 174 ++++++++----- .../ha-form/ha-form-multi_select.ts | 238 ++++++++++-------- .../ha-form-positive_time_period_dict.ts | 4 +- src/components/ha-form/ha-form-select.ts | 137 +++++----- src/components/ha-form/ha-form-string.ts | 95 ++++--- src/components/ha-form/ha-form.ts | 218 +++++----------- src/components/ha-form/types.ts | 86 +++++++ src/data/data_entry_flow.ts | 2 +- src/data/device_automation.ts | 2 +- src/data/hassio/addon.ts | 2 +- src/data/zha.ts | 2 +- .../show-dialog-data-entry-flow.ts | 2 +- src/dialogs/config-flow/step-flow-form.ts | 77 +++--- src/dialogs/config-flow/styles.ts | 4 +- src/resources/ha-style.ts | 16 +- src/resources/styles.ts | 49 ++++ src/translations/en.json | 2 +- yarn.lock | 146 ++++++++++- 29 files changed, 1121 insertions(+), 624 deletions(-) create mode 100644 src/components/ha-form/compute-initial-ha-form-data.ts create mode 100644 src/components/ha-form/types.ts diff --git a/gallery/src/demos/demo-ha-form.ts b/gallery/src/demos/demo-ha-form.ts index 9f5f9a3d54..38266cd602 100644 --- a/gallery/src/demos/demo-ha-form.ts +++ b/gallery/src/demos/demo-ha-form.ts @@ -1,23 +1,26 @@ /* eslint-disable lit/no-template-arrow */ +import "@material/mwc-button"; import { LitElement, TemplateResult, css, html } from "lit"; import { customElement } from "lit/decorators"; -import "../../../src/components/ha-form/ha-form"; +import { computeInitialHaFormData } from "../../../src/components/ha-form/compute-initial-ha-form-data"; import "../../../src/components/ha-card"; import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element"; -import type { HaFormSchema } from "../../../src/components/ha-form/ha-form"; +import type { HaFormSchema } from "../../../src/components/ha-form/types"; +import "../../../src/components/ha-form/ha-form"; const SCHEMAS: { title: string; translations?: Record; error?: Record; schema: HaFormSchema[]; + data?: Record; }[] = [ { title: "Authentication", translations: { username: "Username", password: "Password", - invalid_login: "Invalid login", + invalid_login: "Invalid username or password", }, error: { base: "invalid_login", @@ -57,6 +60,11 @@ const SCHEMAS: { optional: true, default: 10, }, + { + type: "float", + name: "float", + required: true, + }, { type: "string", name: "string", @@ -83,6 +91,80 @@ const SCHEMAS: { optional: true, default: ["default"], }, + { + type: "positive_time_period_dict", + name: "time", + required: true, + }, + ], + }, + { + title: "Numbers", + schema: [ + { + type: "integer", + name: "int", + required: true, + }, + { + type: "integer", + name: "int with default", + optional: true, + default: 10, + }, + { + type: "integer", + name: "int range required", + required: true, + default: 5, + valueMin: 0, + valueMax: 10, + }, + { + type: "integer", + name: "int range optional", + optional: true, + valueMin: 0, + valueMax: 10, + }, + ], + }, + { + title: "select", + schema: [ + { + type: "select", + options: [ + ["default", "Default"], + ["other", "Other"], + ], + name: "select", + required: true, + default: "default", + }, + { + type: "select", + options: [ + ["default", "Default"], + ["other", "Other"], + ], + name: "select optional", + optional: true, + }, + { + type: "select", + options: [ + ["default", "Default"], + ["other", "Other"], + ["uno", "mas"], + ["one", "more"], + ["and", "another_one"], + ["option", "1000"], + ], + name: "select many otions", + optional: true, + default: "default", + }, ], }, { @@ -95,7 +177,7 @@ const SCHEMAS: { other: "Other", }, name: "multi", - optional: true, + required: true, default: ["default"], }, { @@ -108,19 +190,46 @@ const SCHEMAS: { and: "another_one", option: "1000", }, - name: "multi", + name: "multi many otions", optional: true, default: ["default"], }, ], }, + { + title: "Field specific error", + data: { + new_password: "hello", + new_password_2: "bye", + }, + translations: { + new_password: "New Password", + new_password_2: "Re-type Password", + not_match: "The passwords do not match", + }, + error: { + new_password_2: "not_match", + }, + schema: [ + { + type: "string", + name: "new_password", + required: true, + }, + { + type: "string", + name: "new_password_2", + required: true, + }, + ], + }, ]; @customElement("demo-ha-form") class DemoHaForm extends LitElement { - private lightModeData: any = []; - - private darkModeData: any = []; + private data = SCHEMAS.map( + ({ schema, data }) => data || computeInitialHaFormData(schema) + ); protected render(): TemplateResult { return html` @@ -130,38 +239,58 @@ class DemoHaForm extends LitElement { translations[schema.name] || schema.name; const computeError = (error) => translations[error] || error; - return [ - [this.lightModeData, "light"], - [this.darkModeData, "dark"], - ].map( - ([data, type]) => html` -
+ return html` +
+
{ - data[idx] = e.detail.value; + this.data[idx] = e.detail.value; this.requestUpdate(); }} >
+
+ Submit +
-
${JSON.stringify(data[idx], undefined, 2)}
- ` - ); +
+ +
+ { + this.data[idx] = e.detail.value; + this.requestUpdate(); + }} + > +
+
+ Submit +
+
+
${JSON.stringify(this.data[idx], undefined, 2)}
+
+
+ `; })} `; } firstUpdated(changedProps) { super.firstUpdated(changedProps); - this.shadowRoot!.querySelectorAll("[data-type=dark]").forEach((el) => { + this.shadowRoot!.querySelectorAll(".dark").forEach((el) => { applyThemesOnElement( el, { @@ -178,28 +307,63 @@ class DemoHaForm extends LitElement { static styles = css` .row { - margin: 0 auto; - max-width: 800px; display: flex; - padding: 50px; + } + .content { + padding: 50px 0; background-color: var(--primary-background-color); } + .light { + flex: 1; + padding-left: 50px; + padding-right: 50px; + box-sizing: border-box; + } + .light ha-card { + margin-left: auto; + } + .dark { + display: flex; + flex: 1; + padding-left: 50px; + box-sizing: border-box; + flex-wrap: wrap; + } ha-card { - width: 100%; - max-width: 384px; + width: 400px; } pre { - width: 400px; - margin: 0 16px; + width: 300px; + margin: 0 16px 0; overflow: auto; color: var(--primary-text-color); } - @media only screen and (max-width: 800px) { - .row { + .card-actions { + display: flex; + flex-direction: row-reverse; + border-top: none; + } + @media only screen and (max-width: 1500px) { + .light { + flex: initial; + } + } + @media only screen and (max-width: 1000px) { + .light, + .dark { + padding: 16px; + } + .row, + .dark { flex-direction: column; } + ha-card { + margin: 0 auto; + width: 100%; + max-width: 400px; + } pre { - margin: 16px 0; + margin: 16px auto; } } `; diff --git a/hassio/src/addon-view/config/hassio-addon-config.ts b/hassio/src/addon-view/config/hassio-addon-config.ts index ed9ed11c42..4d2ef2acd8 100644 --- a/hassio/src/addon-view/config/hassio-addon-config.ts +++ b/hassio/src/addon-view/config/hassio-addon-config.ts @@ -19,7 +19,7 @@ import "../../../../src/components/ha-button-menu"; import "../../../../src/components/ha-card"; import "../../../../src/components/ha-alert"; import "../../../../src/components/ha-form/ha-form"; -import type { HaFormSchema } from "../../../../src/components/ha-form/ha-form"; +import type { HaFormSchema } from "../../../../src/components/ha-form/types"; import "../../../../src/components/ha-formfield"; import "../../../../src/components/ha-switch"; import "../../../../src/components/ha-yaml-editor"; diff --git a/package.json b/package.json index 3a3dfe5645..fcd9afa372 100644 --- a/package.json +++ b/package.json @@ -60,9 +60,12 @@ "@material/mwc-menu": "0.25.1", "@material/mwc-radio": "0.25.1", "@material/mwc-ripple": "0.25.1", + "@material/mwc-select": "^0.25.1", + "@material/mwc-slider": "^0.25.1", "@material/mwc-switch": "0.25.1", "@material/mwc-tab": "0.25.1", "@material/mwc-tab-bar": "0.25.1", + "@material/mwc-textfield": "^0.25.1", "@material/top-app-bar": "13.0.0-canary.65125b3a6.0", "@mdi/js": "6.2.95", "@mdi/svg": "6.2.95", diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index d046ca6dae..e8062334e1 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -11,12 +11,14 @@ import "./ha-password-manager-polyfill"; import { property, state } from "lit/decorators"; import "../components/ha-form/ha-form"; import "../components/ha-markdown"; +import "../components/ha-alert"; import { AuthProvider } from "../data/auth"; import { DataEntryFlowStep, DataEntryFlowStepForm, } from "../data/data_entry_flow"; import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin"; +import { computeInitialHaFormData } from "../components/ha-form/compute-initial-ha-form-data"; type State = "loading" | "error" | "step"; @@ -31,12 +33,40 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { @state() private _state: State = "loading"; - @state() private _stepData: any = {}; + @state() private _stepData?: Record; @state() private _step?: DataEntryFlowStep; @state() private _errorMessage?: string; + willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!changedProps.has("_step")) { + return; + } + + if (!this._step) { + this._stepData = undefined; + return; + } + + const oldStep = changedProps.get("_step") as HaAuthFlow["_step"]; + + if ( + !oldStep || + this._step.flow_id !== oldStep.flow_id || + (this._step.type === "form" && + oldStep.type === "form" && + this._step.step_id !== oldStep.step_id) + ) { + this._stepData = + this._step.type === "form" + ? computeInitialHaFormData(this._step.data_schema) + : undefined; + } + } + protected render() { return html`
${this._renderForm()}
@@ -76,6 +106,24 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { if (changedProps.has("authProvider")) { this._providerChanged(this.authProvider); } + + if (!changedProps.has("_step") || this._step?.type !== "form") { + return; + } + + // 100ms to give all the form elements time to initialize. + setTimeout(() => { + const form = this.renderRoot.querySelector("ha-form"); + if (form) { + (form as any).focus(); + } + }, 100); + + setTimeout(() => { + this.renderRoot.querySelector( + "ha-password-manager-polyfill" + )!.boundingRect = this.getBoundingClientRect(); + }, 500); } private _renderForm(): TemplateResult { @@ -98,16 +146,20 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { `; case "error": return html` -
+ ${this.localize( "ui.panel.page-authorize.form.error", "error", this._errorMessage )} -
+ `; case "loading": - return html` ${this.localize("ui.panel.page-authorize.form.working")} `; + return html` + + ${this.localize("ui.panel.page-authorize.form.working")} + + `; default: return html``; } @@ -189,7 +241,8 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { return; } - await this._updateStep(data); + this._step = data; + this._state = "step"; } else { this._state = "error"; this._errorMessage = data.message; @@ -220,39 +273,6 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { document.location.assign(url); } - private async _updateStep(step: DataEntryFlowStep) { - let stepData: any = null; - if ( - this._step && - (step.flow_id !== this._step.flow_id || - (step.type === "form" && - this._step.type === "form" && - step.step_id !== this._step.step_id)) - ) { - stepData = {}; - } - this._step = step; - this._state = "step"; - if (stepData != null) { - this._stepData = stepData; - } - - await this.updateComplete; - // 100ms to give all the form elements time to initialize. - setTimeout(() => { - const form = this.renderRoot.querySelector("ha-form"); - if (form) { - (form as any).focus(); - } - }, 100); - - setTimeout(() => { - this.renderRoot.querySelector( - "ha-password-manager-polyfill" - )!.boundingRect = this.getBoundingClientRect(); - }, 500); - } - private _stepDataChanged(ev: CustomEvent) { this._stepData = ev.detail.value; } @@ -316,7 +336,8 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { this._redirect(newStep.result); return; } - await this._updateStep(newStep); + this._step = newStep; + this._state = "step"; } catch (err: any) { // eslint-disable-next-line no-console console.error("Error submitting step", err); @@ -337,9 +358,6 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { margin: 24px 0 8px; text-align: center; } - .error { - color: red; - } `; } } diff --git a/src/auth/ha-authorize.ts b/src/auth/ha-authorize.ts index 9608ad0642..acd98952c9 100644 --- a/src/auth/ha-authorize.ts +++ b/src/auth/ha-authorize.ts @@ -174,6 +174,10 @@ class HaAuthorize extends litLocalizeLiteMixin(LitElement) { display: block; margin-top: 48px; } + ha-auth-flow { + display: block; + margin-top: 24px; + } `; } } diff --git a/src/auth/ha-password-manager-polyfill.ts b/src/auth/ha-password-manager-polyfill.ts index c0c46c0ab0..a0f2488b78 100644 --- a/src/auth/ha-password-manager-polyfill.ts +++ b/src/auth/ha-password-manager-polyfill.ts @@ -2,8 +2,8 @@ import { html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; -import { HaFormSchema } from "../components/ha-form/ha-form"; -import { DataEntryFlowStep } from "../data/data_entry_flow"; +import type { HaFormSchema } from "../components/ha-form/types"; +import type { DataEntryFlowStep } from "../data/data_entry_flow"; declare global { interface HTMLElementTagNameMap { diff --git a/src/components/ha-duration-input.ts b/src/components/ha-duration-input.ts index 32651cb2c2..4af165b74d 100644 --- a/src/components/ha-duration-input.ts +++ b/src/components/ha-duration-input.ts @@ -16,8 +16,6 @@ class HaDurationInput extends LitElement { @property() public label?: string; - @property() public suffix?: string; - @property({ type: Boolean }) public required?: boolean; @property({ type: Boolean }) public enableMillisecond?: boolean; diff --git a/src/components/ha-form/compute-initial-ha-form-data.ts b/src/components/ha-form/compute-initial-ha-form-data.ts new file mode 100644 index 0000000000..0e80433c74 --- /dev/null +++ b/src/components/ha-form/compute-initial-ha-form-data.ts @@ -0,0 +1,37 @@ +import { HaFormSchema } from "./types"; + +export const computeInitialHaFormData = ( + schema: HaFormSchema[] +): Record => { + const data = {}; + schema.forEach((field) => { + if (field.description?.suggested_value) { + data[field.name] = field.description.suggested_value; + } else if ("default" in field) { + data[field.name] = field.default; + } else if (!field.required) { + // Do nothing. + } else if (field.type === "boolean") { + data[field.name] = false; + } else if (field.type === "string") { + data[field.name] = ""; + } else if (field.type === "integer") { + data[field.name] = "valueMin" in field ? field.valueMin : 0; + } else if (field.type === "constant") { + data[field.name] = field.value; + } else if (field.type === "float") { + data[field.name] = 0.0; + } else if (field.type === "select") { + if (field.options.length) { + data[field.name] = field.options[0][0]; + } + } else if (field.type === "positive_time_period_dict") { + data[field.name] = { + hours: 0, + minutes: 0, + seconds: 0, + }; + } + }); + return data; +}; diff --git a/src/components/ha-form/ha-form-boolean.ts b/src/components/ha-form/ha-form-boolean.ts index 2bf264a498..f348013504 100644 --- a/src/components/ha-form/ha-form-boolean.ts +++ b/src/components/ha-form/ha-form-boolean.ts @@ -1,13 +1,14 @@ -import "@polymer/paper-checkbox/paper-checkbox"; -import type { PaperCheckboxElement } from "@polymer/paper-checkbox/paper-checkbox"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import "@material/mwc-formfield"; +import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, query } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import type { HaFormBooleanData, HaFormBooleanSchema, HaFormElement, -} from "./ha-form"; +} from "./types"; +import type { HaCheckbox } from "../ha-checkbox"; +import "../ha-checkbox"; @customElement("ha-form-boolean") export class HaFormBoolean extends LitElement implements HaFormElement { @@ -17,8 +18,6 @@ export class HaFormBoolean extends LitElement implements HaFormElement { @property() public label!: string; - @property() public suffix!: string; - @query("paper-checkbox", true) private _input?: HTMLElement; public focus() { @@ -29,26 +28,20 @@ export class HaFormBoolean extends LitElement implements HaFormElement { protected render(): TemplateResult { return html` - - ${this.label} - + + + `; } private _valueChanged(ev: Event) { fireEvent(this, "value-changed", { - value: (ev.target as PaperCheckboxElement).checked, + value: (ev.target as HaCheckbox).checked, }); } - - static get styles(): CSSResultGroup { - return css` - paper-checkbox { - display: block; - padding: 22px 0; - } - `; - } } declare global { diff --git a/src/components/ha-form/ha-form-constant.ts b/src/components/ha-form/ha-form-constant.ts index d05e9ac5f6..f20c057dfc 100644 --- a/src/components/ha-form/ha-form-constant.ts +++ b/src/components/ha-form/ha-form-constant.ts @@ -1,14 +1,6 @@ -import { - css, - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, -} from "lit"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; -import { fireEvent } from "../../common/dom/fire_event"; -import { HaFormConstantSchema, HaFormElement } from "./ha-form"; +import { HaFormConstantSchema, HaFormElement } from "./types"; @customElement("ha-form-constant") export class HaFormConstant extends LitElement implements HaFormElement { @@ -16,13 +8,6 @@ export class HaFormConstant extends LitElement implements HaFormElement { @property() public label!: string; - protected firstUpdated(changedProps: PropertyValues) { - super.firstUpdated(changedProps); - fireEvent(this, "value-changed", { - value: this.schema.value, - }); - } - protected render(): TemplateResult { return html`${this.label}: ${this.schema.value}`; } diff --git a/src/components/ha-form/ha-form-float.ts b/src/components/ha-form/ha-form-float.ts index 698667eca4..032956a7e5 100644 --- a/src/components/ha-form/ha-form-float.ts +++ b/src/components/ha-form/ha-form-float.ts @@ -1,9 +1,9 @@ -import "@polymer/paper-input/paper-input"; -import type { PaperInputElement } from "@polymer/paper-input/paper-input"; -import { html, LitElement, TemplateResult } from "lit"; +import "@material/mwc-textfield"; +import type { TextField } from "@material/mwc-textfield"; +import { css, html, LitElement, TemplateResult, PropertyValues } from "lit"; import { customElement, property, query } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; -import { HaFormElement, HaFormFloatData, HaFormFloatSchema } from "./ha-form"; +import { HaFormElement, HaFormFloatData, HaFormFloatSchema } from "./types"; @customElement("ha-form-float") export class HaFormFloat extends LitElement implements HaFormElement { @@ -13,9 +13,7 @@ export class HaFormFloat extends LitElement implements HaFormElement { @property() public label!: string; - @property() public suffix!: string; - - @query("paper-input", true) private _input?: HTMLElement; + @query("mwc-textfield") private _input?: HTMLElement; public focus() { if (this._input) { @@ -25,33 +23,58 @@ export class HaFormFloat extends LitElement implements HaFormElement { protected render(): TemplateResult { return html` - - ${this.suffix} - + .suffix=${this.schema.description?.suffix} + .validationMessage=${this.schema.required ? "Required" : undefined} + @input=${this._valueChanged} + > `; } - private get _value() { - return this.data; + protected updated(changedProps: PropertyValues): void { + if (changedProps.has("schema")) { + this.toggleAttribute("own-margin", !!this.schema.required); + } } private _valueChanged(ev: Event) { - const value: number | undefined = (ev.target as PaperInputElement).value - ? Number((ev.target as PaperInputElement).value) - : undefined; - if (this._value === value) { + const source = ev.target as TextField; + const rawValue = source.value; + + let value: number | undefined; + + if (rawValue !== "") { + value = parseFloat(rawValue); + } + + // Detect anything changed + if (this.data === value) { + // parseFloat will drop invalid text at the end, in that case update textfield + const newRawValue = value === undefined ? "" : String(value); + if (source.value !== newRawValue) { + source.value = newRawValue; + return; + } return; } + fireEvent(this, "value-changed", { value, }); } + + static styles = css` + :host([own-margin]) { + margin-bottom: 5px; + } + mwc-textfield { + display: block; + } + `; } declare global { diff --git a/src/components/ha-form/ha-form-integer.ts b/src/components/ha-form/ha-form-integer.ts index 9cb7da2496..9aa54a2e43 100644 --- a/src/components/ha-form/ha-form-integer.ts +++ b/src/components/ha-form/ha-form-integer.ts @@ -1,16 +1,19 @@ -import "@polymer/paper-input/paper-input"; -import type { PaperInputElement } from "@polymer/paper-input/paper-input"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import "@material/mwc-textfield"; +import type { TextField } from "@material/mwc-textfield"; +import "@material/mwc-slider"; +import type { Slider } from "@material/mwc-slider"; +import { + css, + CSSResultGroup, + html, + LitElement, + TemplateResult, + PropertyValues, +} from "lit"; import { customElement, property, query } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import { HaCheckbox } from "../ha-checkbox"; -import "../ha-slider"; -import type { HaSlider } from "../ha-slider"; -import { - HaFormElement, - HaFormIntegerData, - HaFormIntegerSchema, -} from "./ha-form"; +import { HaFormElement, HaFormIntegerData, HaFormIntegerSchema } from "./types"; @customElement("ha-form-integer") export class HaFormInteger extends LitElement implements HaFormElement { @@ -20,10 +23,10 @@ export class HaFormInteger extends LitElement implements HaFormElement { @property() public label?: string; - @property() public suffix?: string; - @query("paper-input ha-slider") private _input?: HTMLElement; + private _lastValue?: HaFormIntegerData; + public focus() { if (this._input) { this._input.focus(); @@ -31,66 +34,112 @@ export class HaFormInteger extends LitElement implements HaFormElement { } protected render(): TemplateResult { - return "valueMin" in this.schema && "valueMax" in this.schema - ? html` -
- ${this.label} -
- ${this.schema.optional && this.schema.default === undefined - ? html` - - ` - : ""} - -
+ if ("valueMin" in this.schema && "valueMax" in this.schema) { + return html` +
+ ${this.label} +
+ ${this.schema.optional + ? html` + + ` + : ""} +
- ` - : html` - - `; +
+ `; + } + + return html` + + `; + } + + protected updated(changedProps: PropertyValues): void { + if (changedProps.has("schema")) { + this.toggleAttribute( + "own-margin", + !("valueMin" in this.schema && "valueMax" in this.schema) && + !!this.schema.required + ); + } } private get _value() { - return ( - this.data || - this.schema.description?.suggested_value || - this.schema.default || - 0 - ); + if (this.data !== undefined) { + return this.data; + } + + if (this.schema.optional) { + return 0; + } + + return this.schema.description?.suggested_value || this.schema.default || 0; } private _handleCheckboxChange(ev: Event) { const checked = (ev.target as HaCheckbox).checked; + let value: HaFormIntegerData | undefined; + if (checked) { + for (const candidate of [ + this._lastValue, + this.schema.description?.suggested_value as HaFormIntegerData, + this.schema.default, + 0, + ]) { + if (candidate !== undefined) { + value = candidate; + break; + } + } + } else { + // We track last value so user can disable and enable a field without losing + // their value. + this._lastValue = this.data; + } fireEvent(this, "value-changed", { - value: checked ? this._value : undefined, + value, }); } private _valueChanged(ev: Event) { - const value = Number((ev.target as PaperInputElement | HaSlider).value); - if (this._value === value) { + const source = ev.target as TextField | Slider; + const rawValue = source.value; + + let value: number | undefined; + + if (rawValue !== "") { + value = parseInt(String(rawValue)); + } + + if (this.data === value) { + // parseInt will drop invalid text at the end, in that case update textfield + const newRawValue = value === undefined ? "" : String(value); + if (source.value !== newRawValue) { + source.value = newRawValue; + } return; } + fireEvent(this, "value-changed", { value, }); @@ -98,12 +147,17 @@ export class HaFormInteger extends LitElement implements HaFormElement { static get styles(): CSSResultGroup { return css` + :host([own-margin]) { + margin-bottom: 5px; + } .flex { display: flex; } - ha-slider { - width: 100%; - margin-right: 16px; + mwc-slider { + flex: 1; + } + mwc-textfield { + display: block; } `; } diff --git a/src/components/ha-form/ha-form-multi_select.ts b/src/components/ha-form/ha-form-multi_select.ts index b947e5ac79..7267856322 100644 --- a/src/components/ha-form/ha-form-multi_select.ts +++ b/src/components/ha-form/ha-form-multi_select.ts @@ -1,19 +1,35 @@ -import { mdiMenuDown } from "@mdi/js"; -import "@polymer/paper-checkbox/paper-checkbox"; -import "@polymer/paper-input/paper-input"; -import "@polymer/paper-item/paper-icon-item"; -import "@polymer/paper-listbox/paper-listbox"; -import "@polymer/paper-menu-button/paper-menu-button"; -import "@polymer/paper-ripple/paper-ripple"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state, query } from "lit/decorators"; +import { mdiMenuDown, mdiMenuUp } from "@mdi/js"; +import "@material/mwc-textfield"; +import "@material/mwc-formfield"; +import { + css, + CSSResultGroup, + html, + LitElement, + TemplateResult, + PropertyValues, +} from "lit"; +import { customElement, property, query, state } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; -import "../ha-svg-icon"; +import "../ha-button-menu"; +import "../ha-icon"; import { HaFormElement, HaFormMultiSelectData, HaFormMultiSelectSchema, -} from "./ha-form"; +} from "./types"; +import "../ha-checkbox"; +import type { HaCheckbox } from "../ha-checkbox"; + +function optionValue(item: string | string[]): string { + return Array.isArray(item) ? item[0] : item; +} + +function optionLabel(item: string | string[]): string { + return Array.isArray(item) ? item[1] || item[0] : item; +} + +const SHOW_ALL_ENTRIES_LIMIT = 6; @customElement("ha-form-multi_select") export class HaFormMultiSelect extends LitElement implements HaFormElement { @@ -23,9 +39,7 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement { @property() public label!: string; - @property() public suffix!: string; - - @state() private _init = false; + @state() private _opened = false; @query("paper-menu-button", true) private _input?: HTMLElement; @@ -36,118 +50,136 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement { } protected render(): TemplateResult { - const options = Array.isArray(this.schema.options) - ? this.schema.options - : Object.entries(this.schema.options!); - + const options = Object.entries(this.schema.options); const data = this.data || []; + + const renderedOptions = options.map((item: string | [string, string]) => { + const value = optionValue(item); + return html` + + + + `; + }); + + // We will just render all checkboxes. + if (options.length < SHOW_ALL_ENTRIES_LIMIT) { + return html`
${this.label}${renderedOptions}
`; + } + return html` - - - - ${ - // TS doesn't work with union array types https://github.com/microsoft/TypeScript/issues/36390 - // @ts-ignore - options.map((item: string | [string, string]) => { - const value = this._optionValue(item); - return html` - - - ${this._optionLabel(item)} - - `; - }) - } - - + + this.schema.options![value] || value) + .join(", ")} + tabindex="-1" + > + + ${renderedOptions} + `; } protected firstUpdated() { this.updateComplete.then(() => { - const input = ( - this.shadowRoot?.querySelector("paper-input")?.inputElement as any - )?.inputElement; - if (input) { - input.style.textOverflow = "ellipsis"; + const { formElement, mdcRoot } = + this.shadowRoot?.querySelector("mwc-textfield") || ({} as any); + if (formElement) { + formElement.style.textOverflow = "ellipsis"; + formElement.style.cursor = "pointer"; + formElement.setAttribute("readonly", ""); + } + if (mdcRoot) { + mdcRoot.style.cursor = "pointer"; } }); } - private _optionValue(item: string | string[]): string { - return Array.isArray(item) ? item[0] : item; - } - - private _optionLabel(item: string | string[]): string { - return Array.isArray(item) ? item[1] || item[0] : item; - } - - private _onSelect(ev: Event) { - ev.stopPropagation(); + protected updated(changedProps: PropertyValues): void { + if (changedProps.has("schema")) { + this.toggleAttribute( + "own-margin", + Object.keys(this.schema.options).length >= SHOW_ALL_ENTRIES_LIMIT && + !!this.schema.required + ); + } } private _valueChanged(ev: CustomEvent): void { - if (!ev.detail.value || !this._init) { - // ignore first call because that is the init of the component - this._init = true; - return; + const { value, checked } = ev.target as HaCheckbox; + + let newValue: string[]; + + if (checked) { + if (!this.data) { + newValue = [value]; + } else if (this.data.includes(value)) { + return; + } else { + newValue = [...this.data, value]; + } + } else { + if (!this.data.includes(value)) { + return; + } + newValue = this.data.filter((v) => v !== value); } - fireEvent( - this, - "value-changed", - { - value: ev.detail.value.map((element) => element.itemValue), - }, - { bubbles: false } - ); + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + private _handleOpen(ev: Event): void { + ev.stopPropagation(); + this._opened = true; + this.toggleAttribute("opened", true); + } + + private _handleClose(ev: Event): void { + ev.stopPropagation(); + this._opened = false; + this.toggleAttribute("opened", false); } static get styles(): CSSResultGroup { return css` - paper-menu-button { + :host([own-margin]) { + margin-bottom: 5px; + } + ha-button-menu, + mwc-textfield, + mwc-formfield { display: block; - padding: 0; - --paper-item-icon-width: 34px; } - paper-ripple { - top: 12px; - left: 0px; - bottom: 8px; - right: 0px; + ha-svg-icon { + color: var(--input-dropdown-icon-color); + position: absolute; + right: 1em; + top: 1em; + cursor: pointer; } - paper-input { - text-overflow: ellipsis; + :host([opened]) ha-svg-icon { + color: var(--primary-color); + } + :host([opened]) ha-button-menu { + --mdc-text-field-idle-line-color: var(--input-hover-line-color); + --mdc-text-field-label-ink-color: var(--primary-color); } `; } 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 7806e5c685..918dd7147b 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 @@ -1,7 +1,7 @@ import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, query } from "lit/decorators"; import "../ha-duration-input"; -import { HaFormElement, HaFormTimeData, HaFormTimeSchema } from "./ha-form"; +import { HaFormElement, HaFormTimeData, HaFormTimeSchema } from "./types"; @customElement("ha-form-positive_time_period_dict") export class HaFormTimePeriod extends LitElement implements HaFormElement { @@ -11,8 +11,6 @@ export class HaFormTimePeriod extends LitElement implements HaFormElement { @property() public label!: string; - @property() public suffix!: string; - @query("ha-time-input", true) private _input?: HTMLElement; public focus() { diff --git a/src/components/ha-form/ha-form-select.ts b/src/components/ha-form/ha-form-select.ts index c796bc8baf..62bd1ec528 100644 --- a/src/components/ha-form/ha-form-select.ts +++ b/src/components/ha-form/ha-form-select.ts @@ -1,15 +1,15 @@ -import "@material/mwc-icon-button/mwc-icon-button"; -import { mdiClose, mdiMenuDown } from "@mdi/js"; -import "@polymer/paper-input/paper-input"; -import "@polymer/paper-item/paper-item"; -import "@polymer/paper-listbox/paper-listbox"; -import "@polymer/paper-menu-button/paper-menu-button"; -import "@polymer/paper-ripple/paper-ripple"; +import "@material/mwc-select"; +import type { Select } from "@material/mwc-select"; +import "@material/mwc-list/mwc-list-item"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, query } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import "../ha-svg-icon"; -import { HaFormElement, HaFormSelectData, HaFormSelectSchema } from "./ha-form"; +import "../ha-radio"; +import { HaFormElement, HaFormSelectData, HaFormSelectSchema } from "./types"; + +import { stopPropagation } from "../../common/dom/stop_propagation"; +import type { HaRadio } from "../ha-radio"; @customElement("ha-form-select") export class HaFormSelect extends LitElement implements HaFormElement { @@ -19,9 +19,7 @@ export class HaFormSelect extends LitElement implements HaFormElement { @property() public label!: string; - @property() public suffix!: string; - - @query("ha-paper-dropdown-menu", true) private _input?: HTMLElement; + @query("mwc-select", true) private _input?: HTMLElement; public focus() { if (this._input) { @@ -30,90 +28,67 @@ export class HaFormSelect extends LitElement implements HaFormElement { } protected render(): TemplateResult { - return html` - - ` + : this.schema.description?.suffix} + .validationMessage=${this.schema.required ? "Required" : undefined} + @input=${this._valueChanged} + > + ${isPassword + ? html` - - ` - : html` - - `; + ` + : ""} + `; + } + + protected updated(changedProps: PropertyValues): void { + if (changedProps.has("schema")) { + this.toggleAttribute("own-margin", !!this.schema.required); + } } private _toggleUnmaskedPassword(): void { @@ -76,10 +87,13 @@ export class HaFormString extends LitElement implements HaFormElement { } private _valueChanged(ev: Event): void { - const value = (ev.target as PaperInputElement).value; + let value: string | undefined = (ev.target as TextField).value; if (this.data === value) { return; } + if (value === "" && this.schema.optional) { + value = undefined; + } fireEvent(this, "value-changed", { value, }); @@ -99,7 +113,20 @@ export class HaFormString extends LitElement implements HaFormElement { static get styles(): CSSResultGroup { return css` + :host { + display: block; + position: relative; + } + :host([own-margin]) { + margin-bottom: 5px; + } + mwc-textfield { + display: block; + } mwc-icon-button { + position: absolute; + top: 1em; + right: 12px; --mdc-icon-button-size: 24px; color: var(--secondary-text-color); } diff --git a/src/components/ha-form/ha-form.ts b/src/components/ha-form/ha-form.ts index 49b19a8328..eae854c4f9 100644 --- a/src/components/ha-form/ha-form.ts +++ b/src/components/ha-form/ha-form.ts @@ -2,7 +2,7 @@ import { css, CSSResultGroup, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import { dynamicElement } from "../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../common/dom/fire_event"; -import { HaDurationData } from "../ha-duration-input"; +import "../ha-alert"; import "./ha-form-boolean"; import "./ha-form-constant"; import "./ha-form-float"; @@ -11,160 +11,79 @@ import "./ha-form-multi_select"; import "./ha-form-positive_time_period_dict"; import "./ha-form-select"; import "./ha-form-string"; +import { HaFormElement, HaFormDataContainer, HaFormSchema } from "./types"; -export type HaFormSchema = - | HaFormConstantSchema - | HaFormStringSchema - | HaFormIntegerSchema - | HaFormFloatSchema - | HaFormBooleanSchema - | HaFormSelectSchema - | HaFormMultiSelectSchema - | HaFormTimeSchema; - -export interface HaFormBaseSchema { - name: string; - default?: HaFormData; - required?: boolean; - optional?: boolean; - description?: { suffix?: string; suggested_value?: HaFormData }; -} - -export interface HaFormConstantSchema extends HaFormBaseSchema { - type: "constant"; - value: string; -} - -export interface HaFormIntegerSchema extends HaFormBaseSchema { - type: "integer"; - default?: HaFormIntegerData; - valueMin?: number; - valueMax?: number; -} - -export interface HaFormSelectSchema extends HaFormBaseSchema { - type: "select"; - options?: string[] | Array<[string, string]>; -} - -export interface HaFormMultiSelectSchema extends HaFormBaseSchema { - type: "multi_select"; - options?: Record | string[] | Array<[string, string]>; -} - -export interface HaFormFloatSchema extends HaFormBaseSchema { - type: "float"; -} - -export interface HaFormStringSchema extends HaFormBaseSchema { - type: "string"; - format?: string; -} - -export interface HaFormBooleanSchema extends HaFormBaseSchema { - type: "boolean"; -} - -export interface HaFormTimeSchema extends HaFormBaseSchema { - type: "positive_time_period_dict"; -} - -export interface HaFormDataContainer { - [key: string]: HaFormData; -} - -export type HaFormData = - | HaFormStringData - | HaFormIntegerData - | HaFormFloatData - | HaFormBooleanData - | HaFormSelectData - | HaFormMultiSelectData - | HaFormTimeData; - -export type HaFormStringData = string; -export type HaFormIntegerData = number; -export type HaFormFloatData = number; -export type HaFormBooleanData = boolean; -export type HaFormSelectData = string; -export type HaFormMultiSelectData = string[]; -export type HaFormTimeData = HaDurationData; - -export interface HaFormElement extends LitElement { - schema: HaFormSchema | HaFormSchema[]; - data?: HaFormDataContainer | HaFormData; - label?: string; - suffix?: string; -} +const getValue = (obj, item) => (obj ? obj[item.name] : null); @customElement("ha-form") export class HaForm extends LitElement implements HaFormElement { - @property() public data!: HaFormDataContainer | HaFormData; + @property() public data!: HaFormDataContainer; - @property() public schema!: HaFormSchema | HaFormSchema[]; + @property() public schema!: HaFormSchema[]; - @property() public error; + @property() public error?: Record; @property() public computeError?: (schema: HaFormSchema, error) => string; @property() public computeLabel?: (schema: HaFormSchema) => string; - @property() public computeSuffix?: (schema: HaFormSchema) => string; - public focus() { - const input = - this.shadowRoot!.getElementById("child-form") || - this.shadowRoot!.querySelector("ha-form"); - if (!input) { + const root = this.shadowRoot?.querySelector(".root"); + if (!root) { return; } - (input as HTMLElement).focus(); + for (const child of root.children) { + if (child.tagName !== "HA-ALERT") { + (child as HTMLElement).focus(); + break; + } + } } protected render() { - if (Array.isArray(this.schema)) { - return html` + return html` +
${this.error && this.error.base ? html` -
+ ${this._computeError(this.error.base, this.schema)} -
+ ` : ""} - ${this.schema.map( - (item) => html` - - ` - )} - `; - } - - return html` - ${this.error - ? html` -
- ${this._computeError(this.error, this.schema)} -
- ` - : ""} - ${dynamicElement(`ha-form-${this.schema.type}`, { - schema: this.schema, - data: this.data, - label: this._computeLabel(this.schema), - suffix: this._computeSuffix(this.schema), - id: "child-form", - })} + ${this.schema.map((item) => { + const error = getValue(this.error, item); + return html` + ${error + ? html` + + ${this._computeError(error, item)} + + ` + : ""} + ${dynamicElement(`ha-form-${item.type}`, { + schema: item, + data: getValue(this.data, item), + label: this._computeLabel(item), + })} + `; + })} +
`; } + protected createRenderRoot() { + const root = super.createRenderRoot(); + // attach it as soon as possible to make sure we fetch all events. + root.addEventListener("value-changed", (ev) => { + ev.stopPropagation(); + const schema = (ev.target as HaFormElement).schema as HaFormSchema; + fireEvent(this, "value-changed", { + value: { ...this.data, [schema.name]: ev.detail.value }, + }); + }); + return root; + } + private _computeLabel(schema: HaFormSchema) { return this.computeLabel ? this.computeLabel(schema) @@ -173,38 +92,25 @@ export class HaForm extends LitElement implements HaFormElement { : ""; } - private _computeSuffix(schema: HaFormSchema) { - return this.computeSuffix - ? this.computeSuffix(schema) - : schema && schema.description - ? schema.description.suffix - : ""; - } - private _computeError(error, schema: HaFormSchema | HaFormSchema[]) { return this.computeError ? this.computeError(error, schema) : error; } - private _getValue(obj, item) { - if (obj) { - return obj[item.name]; - } - return null; - } - - private _valueChanged(ev: CustomEvent) { - ev.stopPropagation(); - const schema = (ev.target as HaFormElement).schema as HaFormSchema; - const data = this.data as HaFormDataContainer; - fireEvent(this, "value-changed", { - value: { ...data, [schema.name]: ev.detail.value }, - }); - } - static get styles(): CSSResultGroup { + // .root has overflow: auto to avoid margin collapse return css` - .error { - color: var(--error-color); + .root { + margin-bottom: -24px; + overflow: auto; + } + .root > * { + display: block; + } + .root > *:not([own-margin]) { + margin-bottom: 24px; + } + ha-alert[own-margin] { + margin-bottom: 4px; } `; } diff --git a/src/components/ha-form/types.ts b/src/components/ha-form/types.ts new file mode 100644 index 0000000000..ad740b625f --- /dev/null +++ b/src/components/ha-form/types.ts @@ -0,0 +1,86 @@ +import type { LitElement } from "lit"; +import type { HaDurationData } from "../ha-duration-input"; + +export type HaFormSchema = + | HaFormConstantSchema + | HaFormStringSchema + | HaFormIntegerSchema + | HaFormFloatSchema + | HaFormBooleanSchema + | HaFormSelectSchema + | HaFormMultiSelectSchema + | HaFormTimeSchema; + +export interface HaFormBaseSchema { + name: string; + default?: HaFormData; + required?: boolean; + optional?: boolean; + description?: { suffix?: string; suggested_value?: HaFormData }; +} + +export interface HaFormConstantSchema extends HaFormBaseSchema { + type: "constant"; + value: string; +} + +export interface HaFormIntegerSchema extends HaFormBaseSchema { + type: "integer"; + default?: HaFormIntegerData; + valueMin?: number; + valueMax?: number; +} + +export interface HaFormSelectSchema extends HaFormBaseSchema { + type: "select"; + options: Array<[string, string]>; +} + +export interface HaFormMultiSelectSchema extends HaFormBaseSchema { + type: "multi_select"; + options: Record; +} + +export interface HaFormFloatSchema extends HaFormBaseSchema { + type: "float"; +} + +export interface HaFormStringSchema extends HaFormBaseSchema { + type: "string"; + format?: string; +} + +export interface HaFormBooleanSchema extends HaFormBaseSchema { + type: "boolean"; +} + +export interface HaFormTimeSchema extends HaFormBaseSchema { + type: "positive_time_period_dict"; +} + +export interface HaFormDataContainer { + [key: string]: HaFormData; +} + +export type HaFormData = + | HaFormStringData + | HaFormIntegerData + | HaFormFloatData + | HaFormBooleanData + | HaFormSelectData + | HaFormMultiSelectData + | HaFormTimeData; + +export type HaFormStringData = string; +export type HaFormIntegerData = number; +export type HaFormFloatData = number; +export type HaFormBooleanData = boolean; +export type HaFormSelectData = string; +export type HaFormMultiSelectData = string[]; +export type HaFormTimeData = HaDurationData; + +export interface HaFormElement extends LitElement { + schema: HaFormSchema | HaFormSchema[]; + data?: HaFormDataContainer | HaFormData; + label?: string; +} diff --git a/src/data/data_entry_flow.ts b/src/data/data_entry_flow.ts index 8be5367632..d617b37020 100644 --- a/src/data/data_entry_flow.ts +++ b/src/data/data_entry_flow.ts @@ -1,5 +1,5 @@ import { Connection } from "home-assistant-js-websocket"; -import { HaFormSchema } from "../components/ha-form/ha-form"; +import type { HaFormSchema } from "../components/ha-form/types"; import { ConfigEntry } from "./config_entries"; export interface DataEntryFlowProgressedEvent { diff --git a/src/data/device_automation.ts b/src/data/device_automation.ts index 0131a451a2..b047f87f1e 100644 --- a/src/data/device_automation.ts +++ b/src/data/device_automation.ts @@ -1,5 +1,5 @@ import { computeStateName } from "../common/entity/compute_state_name"; -import { HaFormSchema } from "../components/ha-form/ha-form"; +import type { HaFormSchema } from "../components/ha-form/types"; import { HomeAssistant } from "../types"; import { BaseTrigger } from "./automation"; diff --git a/src/data/hassio/addon.ts b/src/data/hassio/addon.ts index c2e43ac046..93ac68c506 100644 --- a/src/data/hassio/addon.ts +++ b/src/data/hassio/addon.ts @@ -1,5 +1,5 @@ import { atLeastVersion } from "../../common/config/version"; -import { HaFormSchema } from "../../components/ha-form/ha-form"; +import type { HaFormSchema } from "../../components/ha-form/types"; import { HomeAssistant } from "../../types"; import { SupervisorArch } from "../supervisor/supervisor"; import { diff --git a/src/data/zha.ts b/src/data/zha.ts index b3b3944f1b..b8c99e0e24 100644 --- a/src/data/zha.ts +++ b/src/data/zha.ts @@ -1,5 +1,5 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { HaFormSchema } from "../components/ha-form/ha-form"; +import type { HaFormSchema } from "../components/ha-form/types"; import { HomeAssistant } from "../types"; export interface ZHAEntityReference extends HassEntity { diff --git a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts index 45a914acf8..c1141d729a 100644 --- a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts @@ -1,6 +1,6 @@ import { TemplateResult } from "lit"; import { fireEvent } from "../../common/dom/fire_event"; -import { HaFormSchema } from "../../components/ha-form/ha-form"; +import type { HaFormSchema } from "../../components/ha-form/types"; import { DataEntryFlowStep, DataEntryFlowStepAbort, diff --git a/src/dialogs/config-flow/step-flow-form.ts b/src/dialogs/config-flow/step-flow-form.ts index 1c2d017826..1b7865bd4b 100644 --- a/src/dialogs/config-flow/step-flow-form.ts +++ b/src/dialogs/config-flow/step-flow-form.ts @@ -11,9 +11,11 @@ import { import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-circular-progress"; +import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data"; +import type { HaFormSchema } from "../../components/ha-form/types"; import "../../components/ha-form/ha-form"; -import type { HaFormSchema } from "../../components/ha-form/ha-form"; import "../../components/ha-markdown"; +import "../../components/ha-alert"; import type { DataEntryFlowStepForm } from "../../data/data_entry_flow"; import type { HomeAssistant } from "../../types"; import type { FlowConfig } from "./show-dialog-data-entry-flow"; @@ -37,24 +39,13 @@ class StepFlowForm extends LitElement { const step = this.step; const stepData = this._stepDataProcessed; - const allRequiredInfoFilledIn = - stepData === undefined - ? // If no data filled in, just check that any field is required - step.data_schema.find((field) => !field.optional) === undefined - : // If data is filled in, make sure all required fields are - stepData && - step.data_schema.every( - (field) => - field.optional || !["", undefined].includes(stepData![field.name]) - ); - return html`

${this.flowConfig.renderShowFormStepHeader(this.hass, this.step)}

- ${this._errorMsg - ? html`
${this._errorMsg}
` - : ""} ${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)} + ${this._errorMsg + ? html`${this._errorMsg}` + : ""} - ${this.hass.localize( + + ${this.hass.localize( `ui.panel.config.integrations.config_flow.${ this.step.last_step === false ? "next" : "submit" }` )} - - ${!allRequiredInfoFilledIn - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_flow.not_all_required_fields" - )} - - ` - : html``}
`}
@@ -113,25 +92,35 @@ class StepFlowForm extends LitElement { return this._stepData; } - const data = {}; - this.step.data_schema.forEach((field) => { - if (field.description?.suggested_value) { - data[field.name] = field.description.suggested_value; - } else if ("default" in field) { - data[field.name] = field.default; - } - }); - - this._stepData = data; - return data; + this._stepData = computeInitialHaFormData(this.step.data_schema); + return this._stepData; } private async _submitStep(): Promise { + const stepData = this._stepData || {}; + + const allRequiredInfoFilledIn = + stepData === undefined + ? // If no data filled in, just check that any field is required + this.step.data_schema.find((field) => !field.optional) === undefined + : // If data is filled in, make sure all required fields are + stepData && + this.step.data_schema.every( + (field) => + field.optional || !["", undefined].includes(stepData![field.name]) + ); + + if (!allRequiredInfoFilledIn) { + this._errorMsg = this.hass.localize( + "ui.panel.config.integrations.config_flow.not_all_required_fields" + ); + return; + } + this._loading = true; this._errorMsg = undefined; const flowId = this.step.flow_id; - const stepData = this._stepData || {}; const toSendData = {}; Object.keys(stepData).forEach((key) => { @@ -188,6 +177,12 @@ class StepFlowForm extends LitElement { .submit-spinner { margin-right: 16px; } + + ha-alert, + ha-form { + margin-top: 24px; + display: block; + } `, ]; } diff --git a/src/dialogs/config-flow/styles.ts b/src/dialogs/config-flow/styles.ts index 008fed7c1b..3f9d14b3c4 100644 --- a/src/dialogs/config-flow/styles.ts +++ b/src/dialogs/config-flow/styles.ts @@ -26,8 +26,8 @@ export const configFlowContentStyles = css` .buttons { position: relative; - padding: 8px 8px 8px 24px; - margin: 0; + padding: 8px 16px 8px 24px; + margin: 8px 0 0; color: var(--primary-color); display: flex; justify-content: flex-end; diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts index a570a73c22..053efc7991 100644 --- a/src/resources/ha-style.ts +++ b/src/resources/ha-style.ts @@ -59,7 +59,7 @@ documentContainer.innerHTML = ` /* states */ --state-icon-color: #44739e; - /* an active state is anything that would require attention */ + /* an active state is anything that would require attention */ --state-icon-active-color: #FDD835; /* an error state is anything that would be considered an error */ /* --state-icon-error-color: #db4437; derived from error-color */ @@ -112,6 +112,20 @@ documentContainer.innerHTML = ` --rgb-text-primary-color: 255, 255, 255; --rgb-card-background-color: 255, 255, 255; + /* input components */ + --input-idle-line-color: rgba(0, 0, 0, 0.42); + --input-hover-line-color: rgba(0, 0, 0, 0.87); + --input-disabled-line-color: rgba(0, 0, 0, 0.06); + --input-outlined-idle-border-color: rgba(0, 0, 0, 0.38); + --input-outlined-hover-border-color: rgba(0, 0, 0, 0.87); + --input-outlined-disabled-border-color: rgba(0, 0, 0, 0.06); + --input-fill-color: rgb(245, 245, 245); + --input-disabled-fill-color: rgb(250, 250, 250); + --input-ink-color: rgba(0, 0, 0, 0.87); + --input-label-ink-color: rgba(0, 0, 0, 0.6); + --input-disabled-ink-color: rgba(0, 0, 0, 0.37); + --input-dropdown-icon-color: rgba(0, 0, 0, 0.54); + /* Vaadin typography */ --material-h6-font-size: 1.25rem; --material-small-font-size: 0.875rem; diff --git a/src/resources/styles.ts b/src/resources/styles.ts index 6f7cb696ab..19d0e34433 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -13,6 +13,20 @@ export const darkStyles = { "switch-unchecked-track-color": "#9b9b9b", "divider-color": "rgba(225, 225, 225, .12)", "mdc-ripple-color": "#AAAAAA", + + "input-idle-line-color": "rgba(255, 255, 255, 0.42)", + "input-hover-line-color": "rgba(255, 255, 255, 0.87)", + "input-disabled-line-color": "rgba(255, 255, 255, 0.06)", + "input-outlined-idle-border-color": "rgba(255, 255, 255, 0.38)", + "input-outlined-hover-border-color": "rgba(255, 255, 255, 0.87)", + "input-outlined-disabled-border-color": "rgba(255, 255, 255, 0.06)", + "input-fill-color": "rgb(10, 10, 10)", + "input-disabled-fill-color": "rgb(5, 5, 5)", + "input-ink-color": "rgba(255, 255, 255, 0.87)", + "input-label-ink-color": "rgba(255, 255, 255, 0.6)", + "input-disabled-ink-color": "rgba(255, 255, 255, 0.37)", + "input-dropdown-icon-color": "rgba(255, 255, 255, 0.54)", + "codemirror-keyword": "#C792EA", "codemirror-operator": "#89DDFF", "codemirror-variable": "#f07178", @@ -69,6 +83,8 @@ export const derivedStyles = { "paper-slider-container-color": "var(--slider-track-color)", "data-table-background-color": "var(--card-background-color)", "markdown-code-background-color": "var(--primary-background-color)", + + // https://github.com/material-components/material-web/blob/master/docs/theming.md "mdc-theme-primary": "var(--primary-color)", "mdc-theme-secondary": "var(--accent-color)", "mdc-theme-background": "var(--primary-background-color)", @@ -80,6 +96,7 @@ export const derivedStyles = { "mdc-theme-text-primary-on-background": "var(--primary-text-color)", "mdc-theme-text-secondary-on-background": "var(--secondary-text-color)", "mdc-theme-text-icon-on-background": "var(--secondary-text-color)", + "mdc-theme-error": "var(--error-color)", "app-header-text-color": "var(--text-primary-color)", "app-header-background-color": "var(--primary-color)", "mdc-checkbox-unchecked-color": "rgba(var(--rgb-primary-text-color), 0.54)", @@ -90,6 +107,38 @@ export const derivedStyles = { "mdc-button-disabled-ink-color": "var(--disabled-text-color)", "mdc-button-outline-color": "var(--divider-color)", "mdc-dialog-scroll-divider-color": "var(--divider-color)", + + "mdc-text-field-idle-line-color": "var(--input-idle-line-color)", + "mdc-text-field-hover-line-color": "var(--input-hover-line-color)", + "mdc-text-field-disabled-line-color": "var(--input-disabled-line-color)", + "mdc-text-field-outlined-idle-border-color": + "var(--input-outlined-idle-border-color)", + "mdc-text-field-outlined-hover-border-color": + "var(--input-outlined-hover-border-color)", + "mdc-text-field-outlined-disabled-border-color": + "var(--input-outlined-disabled-border-color)", + "mdc-text-field-fill-color": "var(--input-fill-color)", + "mdc-text-field-disabled-fill-color": "var(--input-disabled-fill-color)", + "mdc-text-field-ink-color": "var(--input-ink-color)", + "mdc-text-field-label-ink-color": "var(--input-label-ink-color)", + "mdc-text-field-disabled-ink-color": "var(--input-disabled-ink-color)", + + "mdc-select-idle-line-color": "var(--input-idle-line-color)", + "mdc-select-hover-line-color": "var(--input-hover-line-color)", + "mdc-select-outlined-idle-border-color": + "var(--input-outlined-idle-border-color)", + "mdc-select-outlined-hover-border-color": + "var(--input-outlined-hover-border-color)", + "mdc-select-outlined-disabled-border-color": + "var(--input-outlined-disabled-border-color)", + "mdc-select-fill-color": "var(--input-fill-color)", + "mdc-select-disabled-fill-color": "var(--input-disabled-fill-color)", + "mdc-select-ink-color": "var(--input-ink-color)", + "mdc-select-label-ink-color": "var(--input-label-ink-color)", + "mdc-select-disabled-ink-color": "var(--input-disabled-ink-color)", + "mdc-select-dropdown-icon-color": "var(--input-dropdown-icon-color)", + "mdc-select-disabled-dropdown-icon-color": "var(--input-disabled-ink-color)", + "chip-background-color": "rgba(var(--rgb-primary-text-color), 0.15)", // Vaadin "material-body-text-color": "var(--primary-text-color)", diff --git a/src/translations/en.json b/src/translations/en.json index 25c2034d3e..56c4430ea7 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3520,7 +3520,7 @@ "form": { "working": "Please wait", "unknown_error": "Something went wrong", - "next": "Next", + "next": "Login", "start_over": "Start over", "error": "Error: {error}", "providers": { diff --git a/yarn.lock b/yarn.lock index 9292abb467..597e15e935 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2149,7 +2149,7 @@ __metadata: languageName: node linkType: hard -"@material/floating-label@npm:13.0.0-canary.65125b3a6.0": +"@material/floating-label@npm:13.0.0-canary.65125b3a6.0, @material/floating-label@npm:=13.0.0-canary.65125b3a6.0": version: 13.0.0-canary.65125b3a6.0 resolution: "@material/floating-label@npm:13.0.0-canary.65125b3a6.0" dependencies: @@ -2197,7 +2197,7 @@ __metadata: languageName: node linkType: hard -"@material/line-ripple@npm:13.0.0-canary.65125b3a6.0": +"@material/line-ripple@npm:13.0.0-canary.65125b3a6.0, @material/line-ripple@npm:=13.0.0-canary.65125b3a6.0": version: 13.0.0-canary.65125b3a6.0 resolution: "@material/line-ripple@npm:13.0.0-canary.65125b3a6.0" dependencies: @@ -2359,6 +2359,18 @@ __metadata: languageName: node linkType: hard +"@material/mwc-floating-label@npm:^0.25.1": + version: 0.25.1 + resolution: "@material/mwc-floating-label@npm:0.25.1" + dependencies: + "@material/floating-label": =13.0.0-canary.65125b3a6.0 + lit-element: ^3.0.0 + lit-html: ^2.0.0 + tslib: ^2.0.1 + checksum: 22d91998c1d01115e8ac59b71b5fe35cb8dc7c781bedb191377659536c0f190d99d5965cab41e67fe3bfc5100fdcdb0659c3f52b3712e89e4ae3994f5dc8319e + languageName: node + linkType: hard + "@material/mwc-formfield@npm:0.25.1": version: 0.25.1 resolution: "@material/mwc-formfield@npm:0.25.1" @@ -2393,6 +2405,18 @@ __metadata: languageName: node linkType: hard +"@material/mwc-line-ripple@npm:^0.25.1": + version: 0.25.1 + resolution: "@material/mwc-line-ripple@npm:0.25.1" + dependencies: + "@material/line-ripple": =13.0.0-canary.65125b3a6.0 + lit-element: ^3.0.0 + lit-html: ^2.0.0 + tslib: ^2.0.1 + checksum: 5897f55cd11e134a2adea03b4cffaa46bf5d8fb71dff2c0601a53960f4ff35a4ba82ea443bdbe72daca801d3abc1b7a459bd980dbeab59dbd7b216b5e10fc1f2 + languageName: node + linkType: hard + "@material/mwc-linear-progress@npm:0.25.1": version: 0.25.1 resolution: "@material/mwc-linear-progress@npm:0.25.1" @@ -2425,7 +2449,7 @@ __metadata: languageName: node linkType: hard -"@material/mwc-menu@npm:0.25.1": +"@material/mwc-menu@npm:0.25.1, @material/mwc-menu@npm:^0.25.1": version: 0.25.1 resolution: "@material/mwc-menu@npm:0.25.1" dependencies: @@ -2442,6 +2466,19 @@ __metadata: languageName: node linkType: hard +"@material/mwc-notched-outline@npm:^0.25.1": + version: 0.25.1 + resolution: "@material/mwc-notched-outline@npm:0.25.1" + dependencies: + "@material/mwc-base": ^0.25.1 + "@material/notched-outline": =13.0.0-canary.65125b3a6.0 + lit-element: ^3.0.0 + lit-html: ^2.0.0 + tslib: ^2.0.1 + checksum: e86709d2f0b6b118a8ba606055c1e8a633919a834e05b4bf897d472206a6031e9ec20b20cdcccbc1d364939ea948e60aea4132eb17c4225938cc059ab370f301 + languageName: node + linkType: hard + "@material/mwc-radio@npm:0.25.1, @material/mwc-radio@npm:^0.25.1": version: 0.25.1 resolution: "@material/mwc-radio@npm:0.25.1" @@ -2469,6 +2506,44 @@ __metadata: languageName: node linkType: hard +"@material/mwc-select@npm:^0.25.1": + version: 0.25.1 + resolution: "@material/mwc-select@npm:0.25.1" + dependencies: + "@material/dom": =13.0.0-canary.65125b3a6.0 + "@material/floating-label": =13.0.0-canary.65125b3a6.0 + "@material/line-ripple": =13.0.0-canary.65125b3a6.0 + "@material/list": =13.0.0-canary.65125b3a6.0 + "@material/mwc-base": ^0.25.1 + "@material/mwc-floating-label": ^0.25.1 + "@material/mwc-icon": ^0.25.1 + "@material/mwc-line-ripple": ^0.25.1 + "@material/mwc-list": ^0.25.1 + "@material/mwc-menu": ^0.25.1 + "@material/mwc-notched-outline": ^0.25.1 + "@material/select": =13.0.0-canary.65125b3a6.0 + lit-element: ^3.0.0 + lit-html: ^2.0.0 + tslib: ^2.0.1 + checksum: 542c24e93e16d0a9f101765679c14e823a0c6fd3932b6f33e6a0bd440e436265c2571505db850be3dcff09e0a05d79ffecdc55e0e1340ba8722e181ed3667529 + languageName: node + linkType: hard + +"@material/mwc-slider@npm:^0.25.1": + version: 0.25.1 + resolution: "@material/mwc-slider@npm:0.25.1" + dependencies: + "@material/dom": =13.0.0-canary.65125b3a6.0 + "@material/mwc-base": ^0.25.1 + "@material/mwc-ripple": ^0.25.1 + "@material/slider": =13.0.0-canary.65125b3a6.0 + lit-element: ^3.0.0 + lit-html: ^2.0.0 + tslib: ^2.0.1 + checksum: 964ba94e12b2aee8e67b19e0d4fc7b8a8a4945be8bef82aa3a305247b2e318709d1d3ffd9761f87a920f85538a863aca59c2746f8bd85e82125c1d15f98c8768 + languageName: node + linkType: hard + "@material/mwc-switch@npm:0.25.1": version: 0.25.1 resolution: "@material/mwc-switch@npm:0.25.1" @@ -2538,7 +2613,25 @@ __metadata: languageName: node linkType: hard -"@material/notched-outline@npm:13.0.0-canary.65125b3a6.0": +"@material/mwc-textfield@npm:^0.25.1": + version: 0.25.1 + resolution: "@material/mwc-textfield@npm:0.25.1" + dependencies: + "@material/floating-label": =13.0.0-canary.65125b3a6.0 + "@material/line-ripple": =13.0.0-canary.65125b3a6.0 + "@material/mwc-base": ^0.25.1 + "@material/mwc-floating-label": ^0.25.1 + "@material/mwc-line-ripple": ^0.25.1 + "@material/mwc-notched-outline": ^0.25.1 + "@material/textfield": =13.0.0-canary.65125b3a6.0 + lit-element: ^3.0.0 + lit-html: ^2.0.0 + tslib: ^2.0.1 + checksum: 31a0235c4b50dcbff28d913c90be114b2972edceb753b17eb84f47cdb46459716a70ee939e9853b8e9ce86af0105e48fea31700e8bac5c30dfadba0f1d5b2235 + languageName: node + linkType: hard + +"@material/notched-outline@npm:13.0.0-canary.65125b3a6.0, @material/notched-outline@npm:=13.0.0-canary.65125b3a6.0": version: 13.0.0-canary.65125b3a6.0 resolution: "@material/notched-outline@npm:13.0.0-canary.65125b3a6.0" dependencies: @@ -2604,7 +2697,7 @@ __metadata: languageName: node linkType: hard -"@material/select@npm:13.0.0-canary.65125b3a6.0": +"@material/select@npm:13.0.0-canary.65125b3a6.0, @material/select@npm:=13.0.0-canary.65125b3a6.0": version: 13.0.0-canary.65125b3a6.0 resolution: "@material/select@npm:13.0.0-canary.65125b3a6.0" dependencies: @@ -2641,6 +2734,24 @@ __metadata: languageName: node linkType: hard +"@material/slider@npm:=13.0.0-canary.65125b3a6.0": + version: 13.0.0-canary.65125b3a6.0 + resolution: "@material/slider@npm:13.0.0-canary.65125b3a6.0" + dependencies: + "@material/animation": 13.0.0-canary.65125b3a6.0 + "@material/base": 13.0.0-canary.65125b3a6.0 + "@material/dom": 13.0.0-canary.65125b3a6.0 + "@material/elevation": 13.0.0-canary.65125b3a6.0 + "@material/feature-targeting": 13.0.0-canary.65125b3a6.0 + "@material/ripple": 13.0.0-canary.65125b3a6.0 + "@material/rtl": 13.0.0-canary.65125b3a6.0 + "@material/theme": 13.0.0-canary.65125b3a6.0 + "@material/typography": 13.0.0-canary.65125b3a6.0 + tslib: ^2.1.0 + checksum: 26f6de62b296296b196cfa31193e137f91da8a4eb6c1e8c7209a73eac3c2d4bbbb412e020b715942ab861632701a6c9931e8b845a4f7cc92b6700f43b811c50c + languageName: node + linkType: hard + "@material/switch@npm:=13.0.0-canary.65125b3a6.0": version: 13.0.0-canary.65125b3a6.0 resolution: "@material/switch@npm:13.0.0-canary.65125b3a6.0" @@ -2724,6 +2835,28 @@ __metadata: languageName: node linkType: hard +"@material/textfield@npm:=13.0.0-canary.65125b3a6.0": + version: 13.0.0-canary.65125b3a6.0 + resolution: "@material/textfield@npm:13.0.0-canary.65125b3a6.0" + dependencies: + "@material/animation": 13.0.0-canary.65125b3a6.0 + "@material/base": 13.0.0-canary.65125b3a6.0 + "@material/density": 13.0.0-canary.65125b3a6.0 + "@material/dom": 13.0.0-canary.65125b3a6.0 + "@material/feature-targeting": 13.0.0-canary.65125b3a6.0 + "@material/floating-label": 13.0.0-canary.65125b3a6.0 + "@material/line-ripple": 13.0.0-canary.65125b3a6.0 + "@material/notched-outline": 13.0.0-canary.65125b3a6.0 + "@material/ripple": 13.0.0-canary.65125b3a6.0 + "@material/rtl": 13.0.0-canary.65125b3a6.0 + "@material/shape": 13.0.0-canary.65125b3a6.0 + "@material/theme": 13.0.0-canary.65125b3a6.0 + "@material/typography": 13.0.0-canary.65125b3a6.0 + tslib: ^2.1.0 + checksum: 1be6d8c1941729a580cb56fd9079049e9cb0dc9c648708af19683a11bd2f14b21b212cc8cf41f77c8cac9441438fe1f17696b70264daf8e0ff72f22baec7136a + languageName: node + linkType: hard + "@material/theme@npm:13.0.0-canary.65125b3a6.0, @material/theme@npm:=13.0.0-canary.65125b3a6.0": version: 13.0.0-canary.65125b3a6.0 resolution: "@material/theme@npm:13.0.0-canary.65125b3a6.0" @@ -8978,9 +9111,12 @@ fsevents@^1.2.7: "@material/mwc-menu": 0.25.1 "@material/mwc-radio": 0.25.1 "@material/mwc-ripple": 0.25.1 + "@material/mwc-select": ^0.25.1 + "@material/mwc-slider": ^0.25.1 "@material/mwc-switch": 0.25.1 "@material/mwc-tab": 0.25.1 "@material/mwc-tab-bar": 0.25.1 + "@material/mwc-textfield": ^0.25.1 "@material/top-app-bar": 13.0.0-canary.65125b3a6.0 "@mdi/js": 6.2.95 "@mdi/svg": 6.2.95