diff --git a/gallery/src/pages/components/ha-textarea.markdown b/gallery/src/pages/components/ha-textarea.markdown new file mode 100644 index 0000000000..3bab7024d1 --- /dev/null +++ b/gallery/src/pages/components/ha-textarea.markdown @@ -0,0 +1,69 @@ +--- +title: Textarea +--- + +# Textarea `` + +A multiline text input component supporting Home Assistant theming and validation, based on webawesome textarea. +Supports autogrow, hints, validation, and both material and outlined appearances. + +## Implementation + +### Example usage + +```html + + + + + + + +``` + +### API + +This component is based on the webawesome textarea component. + +**Slots** + +- `label`: Custom label content. Overrides the `label` property. +- `hint`: Custom hint content. Overrides the `hint` property. + +**Properties/Attributes** + +| Name | Type | Default | Description | +| ------------------ | -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------ | +| value | String | - | The current value of the textarea. | +| label | String | "" | The textarea's label text. | +| hint | String | "" | The textarea's hint/helper text. | +| placeholder | String | "" | Placeholder text shown when the textarea is empty. | +| rows | Number | 4 | The number of visible text rows. | +| resize | "none"/"vertical"/"horizontal"/"both"/"auto" | "none" | Controls the textarea's resize behavior. | +| readonly | Boolean | false | Makes the textarea readonly. | +| disabled | Boolean | false | Disables the textarea and prevents user interaction. | +| required | Boolean | false | Makes the textarea a required field. | +| auto-validate | Boolean | false | Validates the textarea on blur instead of on form submit. | +| invalid | Boolean | false | Marks the textarea as invalid. | +| validation-message | String | "" | Custom validation message shown when the textarea is invalid. | +| minlength | Number | - | The minimum length of input that will be considered valid. | +| maxlength | Number | - | The maximum length of input that will be considered valid. | +| name | String | - | The name of the textarea, submitted as a name/value pair with form data. | +| autocapitalize | "off"/"none"/"on"/"sentences"/"words"/"characters" | "" | Controls whether and how text input is automatically capitalized. | +| autocomplete | String | - | Indicates whether the browser's autocomplete feature should be used. | +| autofocus | Boolean | false | Automatically focuses the textarea when the page loads. | +| spellcheck | Boolean | true | Enables or disables the browser's spellcheck feature. | +| inputmode | "none"/"text"/"decimal"/"numeric"/"tel"/"search"/"email"/"url" | "" | Hints at the type of data for showing an appropriate virtual keyboard. | +| enterkeyhint | "enter"/"done"/"go"/"next"/"previous"/"search"/"send" | "" | Customizes the label or icon of the Enter key on virtual keyboards. | + +#### CSS Parts + +- `wa-base` - The underlying wa-textarea base wrapper. +- `wa-hint` - The underlying wa-textarea hint container. +- `wa-textarea` - The underlying wa-textarea textarea element. + +**CSS Custom Properties** + +- `--ha-textarea-padding-bottom` - Padding below the textarea host. +- `--ha-textarea-max-height` - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`. +- `--ha-textarea-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`. diff --git a/gallery/src/pages/components/ha-textarea.ts b/gallery/src/pages/components/ha-textarea.ts new file mode 100644 index 0000000000..71660c214b --- /dev/null +++ b/gallery/src/pages/components/ha-textarea.ts @@ -0,0 +1,151 @@ +import type { TemplateResult } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement } from "lit/decorators"; +import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element"; +import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-textarea"; + +@customElement("demo-components-ha-textarea") +export class DemoHaTextarea extends LitElement { + protected render(): TemplateResult { + return html` + ${["light", "dark"].map( + (mode) => html` +
+ +
+

Basic

+
+ + + +
+ +

Autogrow

+
+ + +
+ +

States

+
+ + + +
+
+ + + +
+ +

No label

+
+ + +
+
+
+
+ ` + )} + `; + } + + firstUpdated(changedProps) { + super.firstUpdated(changedProps); + applyThemesOnElement( + this.shadowRoot!.querySelector(".dark"), + { + default_theme: "default", + default_dark_theme: "default", + themes: {}, + darkMode: true, + theme: "default", + }, + undefined, + undefined, + true + ); + } + + static styles = css` + :host { + display: flex; + justify-content: center; + } + .dark, + .light { + display: block; + background-color: var(--primary-background-color); + padding: 0 50px; + } + ha-card { + margin: 24px auto; + } + .card-content { + display: flex; + flex-direction: column; + gap: var(--ha-space-2); + } + h3 { + margin: var(--ha-space-4) 0 var(--ha-space-1) 0; + font-size: var(--ha-font-size-l); + font-weight: var(--ha-font-weight-medium); + } + h3:first-child { + margin-top: 0; + } + .row { + display: flex; + gap: var(--ha-space-4); + } + .row > * { + flex: 1; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-textarea": DemoHaTextarea; + } +} diff --git a/package.json b/package.json index f0c59dea68..a67b58dcf7 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "@material/mwc-radio": "0.27.0", "@material/mwc-select": "0.27.0", "@material/mwc-switch": "0.27.0", - "@material/mwc-textarea": "0.27.0", "@material/mwc-textfield": "0.27.0", "@material/mwc-top-app-bar": "0.27.0", "@material/mwc-top-app-bar-fixed": "0.27.0", diff --git a/src/components/date-picker/ha-date-range-picker.ts b/src/components/date-picker/ha-date-range-picker.ts index 70b3955da0..6d40e319e2 100644 --- a/src/components/date-picker/ha-date-range-picker.ts +++ b/src/components/date-picker/ha-date-range-picker.ts @@ -133,7 +133,8 @@ export class HaDateRangePicker extends LitElement { ${!this.minimal ? html``; } return html` + ${this.label || hasLabelSlot + ? html`${this.label + ? this._renderLabel(this.label, this.required) + : nothing}` + : nothing} +
+ ${this._invalid || this.invalid + ? this.validationMessage || this._textarea?.validationMessage + : this.hint || + (hasHintSlot ? html`` : nothing)} +
+ + `; + } + + static styles = [ + waInputStyles, css` :host { - --mdc-text-field-fill-color: var(--ha-color-form-background); + display: flex; + align-items: flex-start; + padding-bottom: var(--ha-textarea-padding-bottom); } - :host([autogrow]) .mdc-text-field { - position: relative; - min-height: 74px; - min-width: 178px; - max-height: 200px; + + /* Label styling */ + wa-textarea::part(label) { + width: calc(100% - var(--ha-space-2)); + background-color: var(--ha-color-form-background); + transition: + all var(--wa-transition-normal) ease-in-out, + background-color var(--wa-transition-normal) ease-in-out; + padding-inline-start: var(--ha-space-3); + padding-inline-end: var(--ha-space-3); + margin: var(--ha-space-1) var(--ha-space-1) 0; + padding-top: var(--ha-space-4); + white-space: nowrap; + overflow: hidden; } - :host([autogrow]) .mdc-text-field:after { - content: attr(data-value); - margin-top: 23px; - margin-bottom: 9px; - line-height: var(--ha-line-height-normal); - min-height: 42px; - padding: 0px 32px 0 16px; - letter-spacing: var( - --mdc-typography-subtitle1-letter-spacing, - 0.009375em - ); - visibility: hidden; - white-space: pre-wrap; + + :host(:focus-within) wa-textarea::part(label), + :host([focused]) wa-textarea::part(label) { + color: var(--primary-color); } - :host([autogrow]) .mdc-text-field__input { + + wa-textarea.label-raised::part(label), + :host(:focus-within) wa-textarea::part(label), + :host([focused]) wa-textarea::part(label) { + padding-top: var(--ha-space-2); + font-size: var(--ha-font-size-xs); + } + + wa-textarea.no-label::part(label) { + height: 0; + padding: 0; + } + + /* Base styling */ + wa-textarea::part(base) { + min-height: 56px; + padding-top: var(--ha-space-6); + padding-bottom: var(--ha-space-2); + } + + wa-textarea.no-label::part(base) { + padding-top: var(--ha-space-3); + } + + wa-textarea::part(base)::after { + content: ""; position: absolute; - height: calc(100% - 32px); + bottom: 0; + left: 0; + right: 0; + height: 1px; + background-color: var(--ha-color-border-neutral-loud); + transition: + height var(--wa-transition-normal) ease-in-out, + background-color var(--wa-transition-normal) ease-in-out; } - :host([autogrow]) .mdc-text-field.mdc-text-field--no-label:after { - margin-top: 16px; - margin-bottom: 16px; + + :host(:focus-within) wa-textarea::part(base)::after, + :host([focused]) wa-textarea::part(base)::after { + height: 2px; + background-color: var(--primary-color); } - .mdc-floating-label { - inset-inline-start: 16px !important; - inset-inline-end: initial !important; - transform-origin: var(--float-start) top; + + :host(:focus-within) wa-textarea.invalid::part(base)::after, + wa-textarea.invalid:not([disabled])::part(base)::after { + background-color: var(--ha-color-border-danger-normal); } - @media only screen and (min-width: 459px) { - :host([mobile-multiline]) .mdc-text-field__input { - white-space: nowrap; - max-height: 16px; - } + + /* Textarea element styling */ + wa-textarea::part(textarea) { + padding: 0 var(--ha-space-4); + font-family: var(--ha-font-family-body); + font-size: var(--ha-font-size-m); + } + + :host([resize="auto"]) wa-textarea::part(textarea) { + max-height: var(--ha-textarea-max-height, 200px); + overflow-y: auto; + } + + wa-textarea:hover::part(base), + wa-textarea:hover::part(label) { + background-color: var(--ha-color-form-background-hover); + } + + wa-textarea[disabled]::part(textarea) { + cursor: not-allowed; + } + + wa-textarea[disabled]::part(base), + wa-textarea[disabled]::part(label) { + background-color: var(--ha-color-form-background-disabled); } `, ]; diff --git a/src/components/input/ha-input.ts b/src/components/input/ha-input.ts index 7d78deecad..3d1331eabe 100644 --- a/src/components/input/ha-input.ts +++ b/src/components/input/ha-input.ts @@ -11,14 +11,14 @@ import { html, nothing, } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { customElement, property, query } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; -import memoizeOne from "memoize-one"; import { stopPropagation } from "../../common/dom/stop_propagation"; import "../ha-icon-button"; import "../ha-svg-icon"; import "../ha-tooltip"; +import { WaInputMixin, waInputStyles } from "./wa-input-mixin"; export type InputType = | "date" @@ -77,35 +77,16 @@ export type InputType = * @attr {string} validation-message - Custom validation message shown when the input is invalid. */ @customElement("ha-input") -export class HaInput extends LitElement { +export class HaInput extends WaInputMixin(LitElement) { @property({ reflect: true }) appearance: "material" | "outlined" = "material"; @property({ reflect: true }) public type: InputType = "text"; - @property() - public value?: string; - - /** The input's label. */ - @property() - public label = ""; - - /** The input's hint. */ - @property() - public hint? = ""; - /** Adds a clear button when the input is not empty. */ @property({ type: Boolean, attribute: "with-clear" }) public withClear = false; - /** Placeholder text to show as a hint when the input is empty. */ - @property() - public placeholder = ""; - - /** Makes the input readonly. */ - @property({ type: Boolean }) - public readonly = false; - /** Adds a button to toggle the password's visibility. */ @property({ type: Boolean, attribute: "password-toggle" }) public passwordToggle = false; @@ -118,22 +99,10 @@ export class HaInput extends LitElement { @property({ type: Boolean, attribute: "without-spin-buttons" }) public withoutSpinButtons = false; - /** Makes the input a required field. */ - @property({ type: Boolean, reflect: true }) - public required = false; - /** A regular expression pattern to validate input against. */ @property() public pattern?: string; - /** The minimum length of input that will be considered valid. */ - @property({ type: Number }) - public minlength?: number; - - /** The maximum length of input that will be considered valid. */ - @property({ type: Number }) - public maxlength?: number; - /** The input's minimum value. Only applies to date and number input types. */ @property() public min?: number | string; @@ -146,88 +115,13 @@ export class HaInput extends LitElement { @property() public step?: number | "any"; - /** Controls whether and how text input is automatically capitalized. */ - @property() - // eslint-disable-next-line lit/no-native-attributes - public autocapitalize: - | "off" - | "none" - | "on" - | "sentences" - | "words" - | "characters" - | "" = ""; - /** Indicates whether the browser's autocorrect feature is on or off. */ @property({ type: Boolean }) public autocorrect = false; - /** Specifies what permission the browser has to provide assistance in filling out form field values. */ - @property() - public autocomplete?: string; - - /** Indicates that the input should receive focus on page load. */ - @property({ type: Boolean }) - // eslint-disable-next-line lit/no-native-attributes - public autofocus = false; - - /** Used to customize the label or icon of the Enter key on virtual keyboards. */ - @property() - // eslint-disable-next-line lit/no-native-attributes - public enterkeyhint: - | "enter" - | "done" - | "go" - | "next" - | "previous" - | "search" - | "send" - | "" = ""; - - /** Enables spell checking on the input. */ - @property({ type: Boolean }) - // eslint-disable-next-line lit/no-native-attributes - public spellcheck = true; - - /** Tells the browser what type of data will be entered by the user. */ - @property() - // eslint-disable-next-line lit/no-native-attributes - public inputmode: - | "none" - | "text" - | "decimal" - | "numeric" - | "tel" - | "search" - | "email" - | "url" - | "" = ""; - - /** The name of the input, submitted as a name/value pair with form data. */ - @property() - public name?: string; - - /** Disables the form control. */ - @property({ type: Boolean }) - public disabled = false; - - /** Custom validation message to show when the input is invalid. */ - @property({ attribute: "validation-message" }) - public validationMessage? = ""; - - /** When true, validates the input on blur instead of on form submit. */ - @property({ type: Boolean, attribute: "auto-validate" }) - public autoValidate = false; - - @property({ type: Boolean }) - public invalid = false; - @property({ type: Boolean, attribute: "inset-label" }) public insetLabel = false; - @state() - private _invalid = false; - @query("wa-input") private _input?: WaInput; @@ -238,37 +132,8 @@ export class HaInput extends LitElement { "input" ); - static shadowRootOptions: ShadowRootInit = { - mode: "open", - delegatesFocus: true, - }; - - /** Selects all the text in the input. */ - public select(): void { - this._input?.select(); - } - - /** Sets the start and end positions of the text selection (0-based). */ - public setSelectionRange( - selectionStart: number, - selectionEnd: number, - selectionDirection?: "forward" | "backward" | "none" - ): void { - this._input?.setSelectionRange( - selectionStart, - selectionEnd, - selectionDirection - ); - } - - /** Replaces a range of text with a new string. */ - public setRangeText( - replacement: string, - start?: number, - end?: number, - selectMode?: "select" | "start" | "end" | "preserve" - ): void { - this._input?.setRangeText(replacement, start, end, selectMode); + protected get _formControl(): WaInput | undefined { + return this._input; } /** Displays the browser picker for an input element. */ @@ -286,17 +151,6 @@ export class HaInput extends LitElement { this._input?.stepDown(); } - public checkValidity(): boolean { - return this._input?.checkValidity() ?? true; - } - - public reportValidity(): boolean { - const valid = this.checkValidity(); - - this._invalid = !valid; - return valid; - } - protected override async firstUpdated( changedProperties: PropertyValues ): Promise { @@ -345,6 +199,7 @@ export class HaInput extends LitElement { .name=${this.name} .disabled=${this.disabled} class=${classMap({ + input: true, invalid: this.invalid || this._invalid, "label-raised": (this.value !== undefined && this.value !== "") || @@ -365,7 +220,9 @@ export class HaInput extends LitElement { > ${this.label || hasLabelSlot ? html`${this._renderLabel(this.label, this.required)}${this.label + ? this._renderLabel(this.label, this.required) + : nothing}` : nothing} @@ -412,27 +269,6 @@ export class HaInput extends LitElement { return nothing; } - private _handleInput() { - this.value = this._input?.value ?? undefined; - if (this._invalid && this._input?.checkValidity()) { - this._invalid = false; - } - } - - private _handleChange() { - this.value = this._input?.value ?? undefined; - } - - private _handleBlur() { - if (this.autoValidate) { - this._invalid = !this._input?.checkValidity(); - } - } - - private _handleInvalid() { - this._invalid = true; - } - private _syncStartSlotWidth = () => { const startEl = this._input?.shadowRoot?.querySelector( '[part~="start"]' @@ -453,200 +289,128 @@ export class HaInput extends LitElement { } }; - private _renderLabel = memoizeOne((label: string, required: boolean) => { - if (!required) { - return label; - } + static styles = [ + waInputStyles, + css` + :host { + display: flex; + align-items: flex-start; + padding-top: var(--ha-input-padding-top); + padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2)); + text-align: var(--ha-input-text-align, start); + } + :host([appearance="outlined"]) { + padding-bottom: var(--ha-input-padding-bottom); + } - let marker = getComputedStyle(this).getPropertyValue( - "--ha-input-required-marker" - ); + wa-input::part(label) { + padding-inline-start: calc( + var(--start-slot-width, 0px) + var(--ha-space-4) + ); + padding-inline-end: var(--ha-space-4); + padding-top: var(--ha-space-5); + } - if (!marker) { - marker = "*"; - } + :host([appearance="material"]:focus-within) wa-input::part(label) { + color: var(--primary-color); + } - if (marker.startsWith('"') && marker.endsWith('"')) { - marker = marker.slice(1, -1); - } + wa-input.label-raised::part(label), + :host(:focus-within) wa-input::part(label), + :host([type="date"]) wa-input::part(label) { + padding-top: var(--ha-space-3); + font-size: var(--ha-font-size-xs); + } - if (!marker) { - return label; - } + wa-input::part(base) { + height: 56px; + padding: 0 var(--ha-space-4); + } - return `${label}${marker}`; - }); + :host([appearance="outlined"]) wa-input.no-label::part(base) { + height: 32px; + padding: 0 var(--ha-space-2); + } - static styles = css` - :host { - display: flex; - align-items: flex-start; - padding-top: var(--ha-input-padding-top); - padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2)); - text-align: var(--ha-input-text-align, start); - } - :host([appearance="outlined"]) { - padding-bottom: var(--ha-input-padding-bottom); - } - wa-input { - flex: 1; - min-width: 0; - --wa-transition-fast: var(--wa-transition-normal); - position: relative; - } + :host([appearance="outlined"]) wa-input::part(base) { + border: 1px solid var(--ha-color-border-neutral-quiet); + background-color: var(--card-background-color); + border-radius: var(--ha-border-radius-md); + transition: border-color var(--wa-transition-normal) ease-in-out; + } - wa-input::part(label) { - position: absolute; - top: 0; - font-weight: var(--ha-font-weight-normal); - font-family: var(--ha-font-family-body); - transition: all var(--wa-transition-normal) ease-in-out; - color: var(--secondary-text-color); - line-height: var(--ha-line-height-condensed); - z-index: 1; - pointer-events: none; - padding-inline-start: calc( - var(--start-slot-width, 0px) + var(--ha-space-4) - ); - padding-inline-end: var(--ha-space-4); - padding-top: var(--ha-space-5); - font-size: var(--ha-font-size-m); - } + :host([appearance="material"]) ::part(base)::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background-color: var(--ha-color-border-neutral-loud); + transition: + height var(--wa-transition-normal) ease-in-out, + background-color var(--wa-transition-normal) ease-in-out; + } - :host([appearance="material"]:focus-within) wa-input::part(label) { - color: var(--primary-color); - } + :host([appearance="material"]:focus-within) wa-input::part(base)::after { + height: 2px; + background-color: var(--primary-color); + } - :host(:focus-within) wa-input.invalid::part(label), - wa-input.invalid:not([disabled])::part(label) { - color: var(--ha-color-fill-danger-loud-resting); - } + :host([appearance="material"]:focus-within) + wa-input.invalid::part(base)::after, + :host([appearance="material"]) + wa-input.invalid:not([disabled])::part(base)::after { + background-color: var(--ha-color-border-danger-normal); + } - wa-input.label-raised::part(label), - :host(:focus-within) wa-input::part(label), - :host([type="date"]) wa-input::part(label) { - padding-top: var(--ha-space-3); - font-size: var(--ha-font-size-xs); - } + wa-input::part(input) { + padding-top: var(--ha-space-3); + padding-inline-start: var(--input-padding-inline-start, 0); + } - wa-input::part(base) { - height: 56px; - background-color: var(--ha-color-form-background); - border-top-left-radius: var(--ha-border-radius-sm); - border-top-right-radius: var(--ha-border-radius-sm); - border-bottom-left-radius: var(--ha-border-radius-square); - border-bottom-right-radius: var(--ha-border-radius-square); - border: none; - padding: 0 var(--ha-space-4); - position: relative; - transition: background-color var(--wa-transition-normal) ease-in-out; - } + wa-input.no-label::part(input) { + padding-top: 0; + } + :host([type="color"]) wa-input::part(input) { + padding-top: var(--ha-space-6); + cursor: pointer; + } + :host([type="color"]) wa-input.no-label::part(input) { + padding: var(--ha-space-2); + } + :host([type="color"]) wa-input.no-label::part(base) { + padding: 0; + } + wa-input::part(input)::placeholder { + color: var(--ha-color-neutral-60); + } - :host([appearance="outlined"]) wa-input.no-label::part(base) { - height: 32px; - padding: 0 var(--ha-space-2); - } + wa-input::part(base):hover { + background-color: var(--ha-color-form-background-hover); + } - :host([appearance="outlined"]) wa-input::part(base) { - border: 1px solid var(--ha-color-border-neutral-quiet); - background-color: var(--card-background-color); - border-radius: var(--ha-border-radius-md); - transition: border-color var(--wa-transition-normal) ease-in-out; - } + :host([appearance="outlined"]) wa-input::part(base):hover { + border-color: var(--ha-color-border-neutral-normal); + } + :host([appearance="outlined"]:focus-within) wa-input::part(base) { + border-color: var(--primary-color); + } - :host([appearance="material"]) ::part(base)::after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 1px; - background-color: var(--ha-color-border-neutral-loud); - transition: - height var(--wa-transition-normal) ease-in-out, - background-color var(--wa-transition-normal) ease-in-out; - } + wa-input:disabled::part(base) { + background-color: var(--ha-color-form-background-disabled); + } - :host([appearance="material"]:focus-within) wa-input::part(base)::after { - height: 2px; - background-color: var(--primary-color); - } + wa-input::part(end) { + color: var(--ha-color-text-secondary); + } - :host([appearance="material"]:focus-within) - wa-input.invalid::part(base)::after, - :host([appearance="material"]) - wa-input.invalid:not([disabled])::part(base)::after { - background-color: var(--ha-color-border-danger-normal); - } - - wa-input::part(input) { - padding-top: var(--ha-space-3); - padding-inline-start: var(--input-padding-inline-start, 0); - } - - wa-input.no-label::part(input) { - padding-top: 0; - } - :host([type="color"]) wa-input::part(input) { - padding-top: var(--ha-space-6); - cursor: pointer; - } - :host([type="color"]) wa-input.no-label::part(input) { - padding: var(--ha-space-2); - } - :host([type="color"]) wa-input.no-label::part(base) { - padding: 0; - } - wa-input::part(input)::placeholder { - color: var(--ha-color-neutral-60); - } - - :host(:focus-within) wa-input::part(base) { - outline: none; - } - - wa-input::part(base):hover { - background-color: var(--ha-color-form-background-hover); - } - - :host([appearance="outlined"]) wa-input::part(base):hover { - border-color: var(--ha-color-border-neutral-normal); - } - :host([appearance="outlined"]:focus-within) wa-input::part(base) { - border-color: var(--primary-color); - } - - wa-input:disabled::part(base) { - background-color: var(--ha-color-form-background-disabled); - } - - wa-input::part(hint) { - height: var(--ha-space-5); - margin-block-start: 0; - margin-inline-start: var(--ha-space-3); - font-size: var(--ha-font-size-xs); - display: flex; - align-items: center; - color: var(--ha-color-text-secondary); - } - - wa-input.hint-hidden::part(hint) { - height: 0; - } - - .error { - color: var(--ha-color-on-danger-quiet); - } - - wa-input::part(end) { - color: var(--ha-color-text-secondary); - } - - :host([appearance="outlined"]) wa-input.no-label { - --ha-icon-button-size: 24px; - --mdc-icon-size: 18px; - } - `; + :host([appearance="outlined"]) wa-input.no-label { + --ha-icon-button-size: 24px; + --mdc-icon-size: 18px; + } + `, + ]; } declare global { diff --git a/src/components/input/wa-input-mixin.ts b/src/components/input/wa-input-mixin.ts new file mode 100644 index 0000000000..e1aa59b1d5 --- /dev/null +++ b/src/components/input/wa-input-mixin.ts @@ -0,0 +1,353 @@ +import { type LitElement, css } from "lit"; +import { property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import type { Constructor } from "../../types"; + +/** + * Minimal interface for the inner wa-input / wa-textarea element. + */ +export interface WaInput { + value: string | null; + select(): void; + setSelectionRange( + start: number, + end: number, + direction?: "forward" | "backward" | "none" + ): void; + setRangeText( + replacement: string, + start?: number, + end?: number, + selectMode?: "select" | "start" | "end" | "preserve" + ): void; + checkValidity(): boolean; + validationMessage: string; +} + +export interface WaInputMixinInterface { + value?: string; + label: string; + hint?: string; + placeholder: string; + readonly: boolean; + required: boolean; + minlength?: number; + maxlength?: number; + autocapitalize: + | "off" + | "none" + | "on" + | "sentences" + | "words" + | "characters" + | ""; + autocomplete?: string; + autofocus: boolean; + spellcheck: boolean; + inputmode: + | "none" + | "text" + | "decimal" + | "numeric" + | "tel" + | "search" + | "email" + | "url" + | ""; + enterkeyhint: + | "enter" + | "done" + | "go" + | "next" + | "previous" + | "search" + | "send" + | ""; + name?: string; + disabled: boolean; + validationMessage?: string; + autoValidate: boolean; + invalid: boolean; + select(): void; + setSelectionRange( + start: number, + end: number, + direction?: "forward" | "backward" | "none" + ): void; + setRangeText( + replacement: string, + start?: number, + end?: number, + selectMode?: "select" | "start" | "end" | "preserve" + ): void; + checkValidity(): boolean; + reportValidity(): boolean; +} + +export const WaInputMixin = >( + superClass: T +) => { + class FormControlMixinClass extends superClass { + @property() + public value?: string; + + @property() + public label? = ""; + + @property() + public hint? = ""; + + @property() + public placeholder? = ""; + + @property({ type: Boolean }) + public readonly = false; + + @property({ type: Boolean, reflect: true }) + public required = false; + + @property({ type: Number }) + public minlength?: number; + + @property({ type: Number }) + public maxlength?: number; + + @property() + // eslint-disable-next-line lit/no-native-attributes + public autocapitalize: + | "off" + | "none" + | "on" + | "sentences" + | "words" + | "characters" + | "" = ""; + + @property() + public autocomplete?: string; + + @property({ type: Boolean }) + // eslint-disable-next-line lit/no-native-attributes + public autofocus = false; + + @property({ type: Boolean }) + // eslint-disable-next-line lit/no-native-attributes + public spellcheck = true; + + @property() + // eslint-disable-next-line lit/no-native-attributes + public inputmode: + | "none" + | "text" + | "decimal" + | "numeric" + | "tel" + | "search" + | "email" + | "url" + | "" = ""; + + @property() + // eslint-disable-next-line lit/no-native-attributes + public enterkeyhint: + | "enter" + | "done" + | "go" + | "next" + | "previous" + | "search" + | "send" + | "" = ""; + + @property() + public name?: string; + + @property({ type: Boolean }) + public disabled = false; + + @property({ attribute: "validation-message" }) + public validationMessage? = ""; + + @property({ type: Boolean, attribute: "auto-validate" }) + public autoValidate = false; + + @property({ type: Boolean }) + public invalid = false; + + @state() + protected _invalid = false; + + static shadowRootOptions: ShadowRootInit = { + mode: "open", + delegatesFocus: true, + }; + + /** + * Override in subclass to return the inner wa-input / wa-textarea element. + */ + protected get _formControl(): WaInput | undefined { + throw new Error("_formControl getter must be implemented by subclass"); + } + + /** + * Override in subclass to set the CSS custom property name + * used for the required-marker character (e.g. "--ha-input-required-marker"). + */ + protected readonly _requiredMarkerCSSVar: string = + "--ha-input-required-marker"; + + public select(): void { + this._formControl?.select(); + } + + public setSelectionRange( + selectionStart: number, + selectionEnd: number, + selectionDirection?: "forward" | "backward" | "none" + ): void { + this._formControl?.setSelectionRange( + selectionStart, + selectionEnd, + selectionDirection + ); + } + + public setRangeText( + replacement: string, + start?: number, + end?: number, + selectMode?: "select" | "start" | "end" | "preserve" + ): void { + this._formControl?.setRangeText(replacement, start, end, selectMode); + } + + public checkValidity(): boolean { + return this._formControl?.checkValidity() ?? true; + } + + public reportValidity(): boolean { + const valid = this.checkValidity(); + this._invalid = !valid; + return valid; + } + + protected _handleInput(): void { + this.value = this._formControl?.value ?? undefined; + if (this._invalid && this._formControl?.checkValidity()) { + this._invalid = false; + } + } + + protected _handleChange(): void { + this.value = this._formControl?.value ?? undefined; + } + + protected _handleBlur(): void { + if (this.autoValidate) { + this._invalid = !this._formControl?.checkValidity(); + } + } + + protected _handleInvalid(): void { + this._invalid = true; + } + + protected _renderLabel = memoizeOne((label: string, required: boolean) => { + if (!required) { + return label; + } + + let marker = getComputedStyle(this).getPropertyValue( + this._requiredMarkerCSSVar + ); + + if (!marker) { + marker = "*"; + } + + if (marker.startsWith('"') && marker.endsWith('"')) { + marker = marker.slice(1, -1); + } + + if (!marker) { + return label; + } + + return `${label}${marker}`; + }); + } + + return FormControlMixinClass; +}; + +/** + * Shared styles for form controls (ha-input / ha-textarea). + * Both components add the `control` CSS class to the inner wa-input / wa-textarea + * element so these rules can target them with a single selector. + */ +export const waInputStyles = css` + /* Inner element reset */ + .input { + flex: 1; + min-width: 0; + --wa-transition-fast: var(--wa-transition-normal); + position: relative; + } + + /* Label base */ + .input::part(label) { + position: absolute; + top: 0; + font-weight: var(--ha-font-weight-normal); + font-family: var(--ha-font-family-body); + transition: all var(--wa-transition-normal) ease-in-out; + color: var(--secondary-text-color); + line-height: var(--ha-line-height-condensed); + z-index: 1; + pointer-events: none; + font-size: var(--ha-font-size-m); + } + + /* Invalid label */ + :host(:focus-within) .input.invalid::part(label), + .input.invalid:not([disabled])::part(label) { + color: var(--ha-color-fill-danger-loud-resting); + } + + /* Base common */ + .input::part(base) { + background-color: var(--ha-color-form-background); + border-top-left-radius: var(--ha-border-radius-sm); + border-top-right-radius: var(--ha-border-radius-sm); + border-bottom-left-radius: var(--ha-border-radius-square); + border-bottom-right-radius: var(--ha-border-radius-square); + border: none; + position: relative; + transition: background-color var(--wa-transition-normal) ease-in-out; + } + + /* Focus outline removal */ + :host(:focus-within) .input::part(base) { + outline: none; + } + + /* Hint */ + .input::part(hint) { + height: var(--ha-space-5); + margin-block-start: 0; + margin-inline-start: var(--ha-space-3); + font-size: var(--ha-font-size-xs); + display: flex; + align-items: center; + color: var(--ha-color-text-secondary); + } + + .input.hint-hidden::part(hint) { + height: 0; + } + + /* Error hint text */ + .error { + color: var(--ha-color-on-danger-quiet); + } +`; diff --git a/src/components/media-player/ha-browse-media-tts.ts b/src/components/media-player/ha-browse-media-tts.ts index 5fb7b02eb2..d1c9db50cd 100644 --- a/src/components/media-player/ha-browse-media-tts.ts +++ b/src/components/media-player/ha-browse-media-tts.ts @@ -58,7 +58,7 @@ class BrowseMediaTTS extends LitElement {
{ - const message = this.shadowRoot!.querySelector("ha-textarea")!.value; + const message = this.shadowRoot!.querySelector("ha-textarea")!.value ?? ""; this._message = message; const item = { ...this.item }; const query = new URLSearchParams(); diff --git a/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts b/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts index ffa9c57bcc..82f3407362 100644 --- a/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts +++ b/src/panels/config/automation/automation-save-dialog/dialog-automation-save.ts @@ -169,10 +169,9 @@ class DialogAutomationSave extends LitElement implements HassDialog { "ui.panel.config.automation.editor.description.placeholder" )} name="description" - autogrow + resize="auto" .value=${this._newDescription} - .helper=${supportsMarkdownHelper(this.hass.localize)} - helperPersistent + .hint=${supportsMarkdownHelper(this.hass.localize)} @input=${this._valueChanged} >` : nothing} diff --git a/src/panels/config/automation/condition/types/ha-automation-condition-template.ts b/src/panels/config/automation/condition/types/ha-automation-condition-template.ts index 9526fc4ef3..1806cac7f5 100644 --- a/src/panels/config/automation/condition/types/ha-automation-condition-template.ts +++ b/src/panels/config/automation/condition/types/ha-automation-condition-template.ts @@ -1,6 +1,5 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; -import "../../../../../components/ha-textarea"; import type { TemplateCondition } from "../../../../../data/automation"; import type { HomeAssistant } from "../../../../../types"; import type { SchemaUnion } from "../../../../../components/ha-form/types"; diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-template.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-template.ts index 4168e5e7d0..e62a6e1dc2 100644 --- a/src/panels/config/automation/trigger/types/ha-automation-trigger-template.ts +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-template.ts @@ -1,4 +1,3 @@ -import "../../../../../components/ha-textarea"; import type { PropertyValues } from "lit"; import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; diff --git a/src/panels/config/cloud/account/dialog-cloud-support-package.ts b/src/panels/config/cloud/account/dialog-cloud-support-package.ts index 59b8df0c06..af1818d5c6 100644 --- a/src/panels/config/cloud/account/dialog-cloud-support-package.ts +++ b/src/panels/config/cloud/account/dialog-cloud-support-package.ts @@ -8,7 +8,6 @@ import "../../../../components/ha-markdown-element"; import "../../../../components/ha-dialog"; import "../../../../components/ha-select"; import "../../../../components/ha-spinner"; -import "../../../../components/ha-textarea"; import { fetchSupportPackage } from "../../../../data/cloud"; import type { HomeAssistant } from "../../../../types"; import { fileDownload } from "../../../../util/file_download"; diff --git a/src/panels/config/developer-tools/assist/developer-tools-assist.ts b/src/panels/config/developer-tools/assist/developer-tools-assist.ts index 1cfa4cf9a1..07d7854b26 100644 --- a/src/panels/config/developer-tools/assist/developer-tools-assist.ts +++ b/src/panels/config/developer-tools/assist/developer-tools-assist.ts @@ -69,7 +69,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) { } private async _parse() { - const sentences = this._sentencesInput.value + const sentences = (this._sentencesInput.value || "") .split("\n") .filter((a) => a !== ""); const { results } = await debugAgent(this.hass, sentences, this._language!); @@ -139,7 +139,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) { ` : nothing} ` : nothing}