Use MWC components for ha-form (#10120)

This commit is contained in:
Paulus Schoutsen 2021-10-07 12:21:35 -07:00 committed by GitHub
parent fa52442c1c
commit a839494a1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1121 additions and 624 deletions

View File

@ -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<string, string>;
error?: Record<string, string>;
schema: HaFormSchema[];
data?: Record<string, any>;
}[] = [
{
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`
<div class="row" data-type=${type}>
return html`
<div class="row">
<div class="content light">
<ha-card .header=${info.title}>
<div class="card-content">
<ha-form
.data=${data[idx]}
.data=${this.data[idx]}
.schema=${info.schema}
.error=${info.error}
.computeError=${computeError}
.computeLabel=${computeLabel}
@value-changed=${(e) => {
data[idx] = e.detail.value;
this.data[idx] = e.detail.value;
this.requestUpdate();
}}
></ha-form>
</div>
<div class="card-actions">
<mwc-button>Submit</mwc-button>
</div>
</ha-card>
<pre>${JSON.stringify(data[idx], undefined, 2)}</pre>
</div>
`
);
<div class="content dark">
<ha-card .header=${info.title}>
<div class="card-content">
<ha-form
.data=${this.data[idx]}
.schema=${info.schema}
.error=${info.error}
.computeError=${computeError}
.computeLabel=${computeLabel}
@value-changed=${(e) => {
this.data[idx] = e.detail.value;
this.requestUpdate();
}}
></ha-form>
</div>
<div class="card-actions">
<mwc-button>Submit</mwc-button>
</div>
</ha-card>
<pre>${JSON.stringify(this.data[idx], undefined, 2)}</pre>
</div>
</div>
`;
})}
`;
}
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;
}
}
`;

View File

@ -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";

View File

@ -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",

View File

@ -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<string, any>;
@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`
<form>${this._renderForm()}</form>
@ -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`
<div class="error">
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-authorize.form.error",
"error",
this._errorMessage
)}
</div>
</ha-alert>
`;
case "loading":
return html` ${this.localize("ui.panel.page-authorize.form.working")} `;
return html`
<ha-alert alert-type="info">
${this.localize("ui.panel.page-authorize.form.working")}
</ha-alert>
`;
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;
}
`;
}
}

View File

@ -174,6 +174,10 @@ class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
display: block;
margin-top: 48px;
}
ha-auth-flow {
display: block;
margin-top: 24px;
}
`;
}
}

View File

@ -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 {

View File

@ -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;

View File

@ -0,0 +1,37 @@
import { HaFormSchema } from "./types";
export const computeInitialHaFormData = (
schema: HaFormSchema[]
): Record<string, any> => {
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;
};

View File

@ -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`
<paper-checkbox .checked=${this.data} @change=${this._valueChanged}>
${this.label}
</paper-checkbox>
<mwc-formfield .label=${this.label}>
<ha-checkbox
.checked=${this.data}
@change=${this._valueChanged}
></ha-checkbox>
</mwc-formfield>
`;
}
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 {

View File

@ -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`<span class="label">${this.label}</span>: ${this.schema.value}`;
}

View File

@ -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`
<paper-input
<mwc-textfield
.label=${this.label}
.value=${this._value}
.value=${this.data !== undefined ? this.data : ""}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
@value-changed=${this._valueChanged}
>
<span suffix slot="suffix">${this.suffix}</span>
</paper-input>
.suffix=${this.schema.description?.suffix}
.validationMessage=${this.schema.required ? "Required" : undefined}
@input=${this._valueChanged}
></mwc-textfield>
`;
}
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 {

View File

@ -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`
<div>
${this.label}
<div class="flex">
${this.schema.optional && this.schema.default === undefined
? html`
<ha-checkbox
@change=${this._handleCheckboxChange}
.checked=${this.data !== undefined}
></ha-checkbox>
`
: ""}
<ha-slider
pin
editable
.value=${this._value}
.min=${this.schema.valueMin}
.max=${this.schema.valueMax}
.disabled=${this.data === undefined &&
this.schema.optional &&
this.schema.default === undefined}
@value-changed=${this._valueChanged}
></ha-slider>
</div>
if ("valueMin" in this.schema && "valueMax" in this.schema) {
return html`
<div>
${this.label}
<div class="flex">
${this.schema.optional
? html`
<ha-checkbox
@change=${this._handleCheckboxChange}
.checked=${this.data !== undefined}
></ha-checkbox>
`
: ""}
<mwc-slider
discrete
.value=${this._value}
.min=${this.schema.valueMin}
.max=${this.schema.valueMax}
.disabled=${this.data === undefined && this.schema.optional}
@change=${this._valueChanged}
></mwc-slider>
</div>
`
: html`
<paper-input
type="number"
.label=${this.label}
.value=${this._value}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
@value-changed=${this._valueChanged}
></paper-input>
`;
</div>
`;
}
return html`
<mwc-textfield
type="number"
.label=${this.label}
.value=${this.data !== undefined ? this.data : ""}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.suffix=${this.schema.description?.suffix}
.validationMessage=${this.schema.required ? "Required" : undefined}
@input=${this._valueChanged}
></mwc-textfield>
`;
}
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;
}
`;
}

View File

@ -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`
<mwc-formfield .label=${optionLabel(item)}>
<ha-checkbox
.checked=${data.includes(value)}
.value=${value}
@change=${this._valueChanged}
></ha-checkbox>
</mwc-formfield>
`;
});
// We will just render all checkboxes.
if (options.length < SHOW_ALL_ENTRIES_LIMIT) {
return html`<div>${this.label}${renderedOptions}</div> `;
}
return html`
<paper-menu-button horizontal-align="right" vertical-offset="8">
<div class="dropdown-trigger" slot="dropdown-trigger">
<paper-ripple></paper-ripple>
<paper-input
id="input"
type="text"
readonly
value=${data
.map((value) => this.schema.options![value] || value)
.join(", ")}
label=${this.label}
input-role="button"
input-aria-haspopup="listbox"
autocomplete="off"
>
<ha-svg-icon
.path=${mdiMenuDown}
suffix
slot="suffix"
></ha-svg-icon>
</paper-input>
</div>
<paper-listbox
multi
slot="dropdown-content"
attr-for-selected="item-value"
.selectedValues=${data}
@selected-items-changed=${this._valueChanged}
@iron-select=${this._onSelect}
>
${
// 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`
<paper-icon-item .itemValue=${value}>
<paper-checkbox
.checked=${data.includes(value)}
slot="item-icon"
></paper-checkbox>
${this._optionLabel(item)}
</paper-icon-item>
`;
})
}
</paper-listbox>
</paper-menu-button>
<ha-button-menu
fixed
corner="BOTTOM_START"
@opened=${this._handleOpen}
@closed=${this._handleClose}
>
<mwc-textfield
slot="trigger"
.label=${this.label}
.value=${data
.map((value) => this.schema.options![value] || value)
.join(", ")}
tabindex="-1"
></mwc-textfield>
<ha-svg-icon
slot="trigger"
.path=${this._opened ? mdiMenuUp : mdiMenuDown}
></ha-svg-icon>
${renderedOptions}
</ha-button-menu>
`;
}
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);
}
`;
}

View File

@ -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() {

View File

@ -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`
<paper-menu-button horizontal-align="right" vertical-offset="8">
<div class="dropdown-trigger" slot="dropdown-trigger">
<paper-ripple></paper-ripple>
<paper-input
id="input"
type="text"
readonly
value=${this.data}
label=${this.label}
input-role="button"
input-aria-haspopup="listbox"
autocomplete="off"
>
${this.data && this.schema.optional
? html`<mwc-icon-button
slot="suffix"
class="clear-button"
@click=${this._clearValue}
>
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</mwc-icon-button>`
: ""}
<mwc-icon-button slot="suffix">
<ha-svg-icon .path=${mdiMenuDown}></ha-svg-icon>
</mwc-icon-button>
</paper-input>
if (!this.schema.optional && this.schema.options!.length < 6) {
return html`
<div>
${this.label}
${this.schema.options.map(
([value, label]) => html`
<mwc-formfield .label=${label}>
<ha-radio
.checked=${value === this.data}
.value=${value}
@change=${this._valueChanged}
></ha-radio>
</mwc-formfield>
`
)}
</div>
<paper-listbox
slot="dropdown-content"
attr-for-selected="item-value"
.selected=${this.data}
@selected-item-changed=${this._valueChanged}
>
${
// TS doesn't work with union array types https://github.com/microsoft/TypeScript/issues/36390
// @ts-ignore
this.schema.options!.map(
(item: string | [string, string]) => html`
<paper-item .itemValue=${this._optionValue(item)}>
${this._optionLabel(item)}
</paper-item>
`
)
}
</paper-listbox>
</paper-menu-button>
`;
}
return html`
<mwc-select
fixedMenuPosition
.label=${this.label}
.value=${this.data}
@closed=${stopPropagation}
@selected=${this._valueChanged}
>
${this.schema.optional
? html`<mwc-list-item value=""></mwc-list-item>`
: ""}
${this.schema.options!.map(
([value, label]) => html`
<mwc-list-item .value=${value}>${label}</mwc-list-item>
`
)}
</mwc-select>
`;
}
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 _clearValue(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: undefined });
}
private _valueChanged(ev: CustomEvent) {
if (!ev.detail.value) {
ev.stopPropagation();
let value: string | undefined = (ev.target as Select | HaRadio).value;
if (value === this.data) {
return;
}
if (value === "") {
value = undefined;
}
fireEvent(this, "value-changed", {
value: ev.detail.value.itemValue,
value,
});
}
static get styles(): CSSResultGroup {
return css`
paper-menu-button {
mwc-select,
mwc-formfield {
display: block;
padding: 0;
}
paper-input > mwc-icon-button {
--mdc-icon-button-size: 24px;
padding: 2px;
}
.clear-button {
color: var(--secondary-text-color);
}
`;
}

View File

@ -1,8 +1,14 @@
import "@material/mwc-icon-button/mwc-icon-button";
import { mdiEye, mdiEyeOff } from "@mdi/js";
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 {
css,
CSSResultGroup,
html,
LitElement,
TemplateResult,
PropertyValues,
} from "lit";
import { customElement, property, state, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-svg-icon";
@ -10,7 +16,7 @@ import type {
HaFormElement,
HaFormStringData,
HaFormStringSchema,
} from "./ha-form";
} from "./types";
const MASKED_FIELDS = ["password", "secret", "token"];
@ -22,11 +28,9 @@ export class HaFormString extends LitElement implements HaFormElement {
@property() public label!: string;
@property() public suffix!: string;
@state() private _unmaskedPassword = false;
@query("paper-input") private _input?: HTMLElement;
@query("mwc-textfield") private _input?: HTMLElement;
public focus(): void {
if (this._input) {
@ -35,20 +39,31 @@ export class HaFormString extends LitElement implements HaFormElement {
}
protected render(): TemplateResult {
return MASKED_FIELDS.some((field) => this.schema.name.includes(field))
? html`
<paper-input
.type=${this._unmaskedPassword ? "text" : "password"}
.label=${this.label}
.value=${this.data}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
@value-changed=${this._valueChanged}
>
const isPassword = MASKED_FIELDS.some((field) =>
this.schema.name.includes(field)
);
return html`
<mwc-textfield
.type=${!isPassword
? this._stringType
: this._unmaskedPassword
? "text"
: "password"}
.label=${this.label}
.value=${this.data || ""}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.suffix=${isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix}
.validationMessage=${this.schema.required ? "Required" : undefined}
@input=${this._valueChanged}
></mwc-textfield>
${isPassword
? html`
<mwc-icon-button
toggles
slot="suffix"
id="iconButton"
title="Click to toggle between masked and clear password"
@click=${this._toggleUnmaskedPassword}
tabindex="-1"
@ -56,19 +71,15 @@ export class HaFormString extends LitElement implements HaFormElement {
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-svg-icon>
</mwc-icon-button>
</paper-input>
`
: html`
<paper-input
.type=${this._stringType}
.label=${this.label}
.value=${this.data}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
error-message="Required"
@value-changed=${this._valueChanged}
></paper-input>
`;
`
: ""}
`;
}
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);
}

View File

@ -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, string> | 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<string, string>;
@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`
<div class="root">
${this.error && this.error.base
? html`
<div class="error">
<ha-alert alert-type="error">
${this._computeError(this.error.base, this.schema)}
</div>
</ha-alert>
`
: ""}
${this.schema.map(
(item) => html`
<ha-form
.data=${this._getValue(this.data, item)}
.schema=${item}
.error=${this._getValue(this.error, item)}
@value-changed=${this._valueChanged}
.computeError=${this.computeError}
.computeLabel=${this.computeLabel}
.computeSuffix=${this.computeSuffix}
></ha-form>
`
)}
`;
}
return html`
${this.error
? html`
<div class="error">
${this._computeError(this.error, this.schema)}
</div>
`
: ""}
${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`
<ha-alert own-margin alert-type="error">
${this._computeError(error, item)}
</ha-alert>
`
: ""}
${dynamicElement(`ha-form-${item.type}`, {
schema: item,
data: getValue(this.data, item),
label: this._computeLabel(item),
})}
`;
})}
</div>
`;
}
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;
}
`;
}

View File

@ -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<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;
}

View File

@ -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 {

View File

@ -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";

View File

@ -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 {

View File

@ -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 {

View File

@ -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,

View File

@ -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`
<h2>${this.flowConfig.renderShowFormStepHeader(this.hass, this.step)}</h2>
<div class="content">
${this._errorMsg
? html` <div class="error">${this._errorMsg}</div> `
: ""}
${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)}
${this._errorMsg
? html`<ha-alert alert-type="error">${this._errorMsg}</ha-alert>`
: ""}
<ha-form
.data=${stepData}
@value-changed=${this._stepDataChanged}
@ -73,25 +64,13 @@ class StepFlowForm extends LitElement {
`
: html`
<div>
<mwc-button
@click=${this._submitStep}
.disabled=${!allRequiredInfoFilledIn}
>${this.hass.localize(
<mwc-button @click=${this._submitStep}>
${this.hass.localize(
`ui.panel.config.integrations.config_flow.${
this.step.last_step === false ? "next" : "submit"
}`
)}
</mwc-button>
${!allRequiredInfoFilledIn
? html`
<paper-tooltip animation-delay="0" position="left"
>${this.hass.localize(
"ui.panel.config.integrations.config_flow.not_all_required_fields"
)}
</paper-tooltip>
`
: html``}
</div>
`}
</div>
@ -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<void> {
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;
}
`,
];
}

View File

@ -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;

View File

@ -59,7 +59,7 @@ documentContainer.innerHTML = `<custom-style>
/* 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 = `<custom-style>
--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;

View File

@ -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)",

View File

@ -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": {

146
yarn.lock
View File

@ -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