mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-01 00:14:03 +00:00
Compare commits
1 Commits
dev
...
webawseome
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e0c95c82 |
69
gallery/src/pages/components/ha-textarea.markdown
Normal file
69
gallery/src/pages/components/ha-textarea.markdown
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Textarea
|
||||
---
|
||||
|
||||
# Textarea `<ha-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
|
||||
<ha-textarea label="Description" value="Hello world"></ha-textarea>
|
||||
|
||||
<ha-textarea label="Notes" placeholder="Type here..." autogrow></ha-textarea>
|
||||
|
||||
<ha-textarea label="Required field" required></ha-textarea>
|
||||
|
||||
<ha-textarea label="Disabled" disabled value="Can't edit this"></ha-textarea>
|
||||
```
|
||||
|
||||
### 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 `"*"`.
|
||||
151
gallery/src/pages/components/ha-textarea.ts
Normal file
151
gallery/src/pages/components/ha-textarea.ts
Normal file
@@ -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`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-textarea in ${mode}">
|
||||
<div class="card-content">
|
||||
<h3>Basic</h3>
|
||||
<div class="row">
|
||||
<ha-textarea label="Default"></ha-textarea>
|
||||
<ha-textarea
|
||||
label="With value"
|
||||
value="Hello world"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="With placeholder"
|
||||
placeholder="Type here..."
|
||||
></ha-textarea>
|
||||
</div>
|
||||
|
||||
<h3>Autogrow</h3>
|
||||
<div class="row">
|
||||
<ha-textarea
|
||||
label="Autogrow empty"
|
||||
resize="auto"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="Autogrow with value"
|
||||
resize="auto"
|
||||
value="This textarea will grow as you type more content into it. Try adding more lines to see the effect."
|
||||
></ha-textarea>
|
||||
</div>
|
||||
|
||||
<h3>States</h3>
|
||||
<div class="row">
|
||||
<ha-textarea
|
||||
label="Disabled"
|
||||
disabled
|
||||
value="Disabled"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="Readonly"
|
||||
readonly
|
||||
value="Readonly"
|
||||
></ha-textarea>
|
||||
<ha-textarea label="Required" required></ha-textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<ha-textarea
|
||||
label="Invalid"
|
||||
invalid
|
||||
validation-message="This field is required"
|
||||
value=""
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="With hint"
|
||||
hint="Supports Markdown"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="With rows"
|
||||
.rows=${6}
|
||||
placeholder="6 rows"
|
||||
></ha-textarea>
|
||||
</div>
|
||||
|
||||
<h3>No label</h3>
|
||||
<div class="row">
|
||||
<ha-textarea
|
||||
placeholder="No label, just placeholder"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
autogrow
|
||||
placeholder="No label, autogrow"
|
||||
></ha-textarea>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -133,7 +133,8 @@ export class HaDateRangePicker extends LitElement {
|
||||
${!this.minimal
|
||||
? html`<ha-textarea
|
||||
id="field"
|
||||
mobile-multiline
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${(isThisYear(this.startDate)
|
||||
@@ -336,14 +337,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
private _setTextareaFocusStyle(focused: boolean) {
|
||||
const textarea = this.renderRoot.querySelector("ha-textarea");
|
||||
if (textarea) {
|
||||
const foundation = (textarea as any).mdcFoundation;
|
||||
if (foundation) {
|
||||
if (focused) {
|
||||
foundation.activateFocus();
|
||||
} else {
|
||||
foundation.deactivateFocus();
|
||||
}
|
||||
}
|
||||
textarea.setFocused(focused);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,15 +65,14 @@ export class HaTextSelector extends LitElement {
|
||||
.label=${this.label}
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.value || ""}
|
||||
.helper=${this.helper}
|
||||
helperPersistent
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
@input=${this._handleChange}
|
||||
autocapitalize="none"
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
spellcheck="false"
|
||||
.required=${this.required}
|
||||
autogrow
|
||||
resize="auto"
|
||||
></ha-textarea>`;
|
||||
}
|
||||
return html`<ha-input
|
||||
|
||||
@@ -1,66 +1,249 @@
|
||||
import { TextAreaBase } from "@material/mwc-textarea/mwc-textarea-base";
|
||||
import { styles as textfieldStyles } from "@material/mwc-textfield/mwc-textfield.css";
|
||||
import { styles as textareaStyles } from "@material/mwc-textarea/mwc-textarea.css";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "@home-assistant/webawesome/dist/components/textarea/textarea";
|
||||
import type WaTextarea from "@home-assistant/webawesome/dist/components/textarea/textarea";
|
||||
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { WaInputMixin, waInputStyles } from "./input/wa-input-mixin";
|
||||
|
||||
/**
|
||||
* Home Assistant textarea component
|
||||
*
|
||||
* @element ha-textarea
|
||||
* @extends {LitElement}
|
||||
*
|
||||
* @summary
|
||||
* A multi-line text input component supporting Home Assistant theming and validation, based on webawesome textarea.
|
||||
*
|
||||
* @slot label - Custom label content. Overrides the `label` property.
|
||||
* @slot hint - Custom hint content. Overrides the `hint` property.
|
||||
*
|
||||
* @csspart wa-base - The underlying wa-textarea base wrapper.
|
||||
* @csspart wa-hint - The underlying wa-textarea hint container.
|
||||
* @csspart wa-textarea - The underlying wa-textarea textarea element.
|
||||
*
|
||||
* @cssprop --ha-textarea-padding-bottom - Padding below the textarea host.
|
||||
* @cssprop --ha-textarea-max-height - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`.
|
||||
* @cssprop --ha-textarea-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
*
|
||||
* @attr {string} label - The textarea's label text.
|
||||
* @attr {string} hint - The textarea's hint/helper text.
|
||||
* @attr {string} placeholder - Placeholder text shown when the textarea is empty.
|
||||
* @attr {boolean} readonly - Makes the textarea readonly.
|
||||
* @attr {boolean} disabled - Disables the textarea and prevents user interaction.
|
||||
* @attr {boolean} required - Makes the textarea a required field.
|
||||
* @attr {number} rows - Number of visible text rows.
|
||||
* @attr {number} minlength - Minimum number of characters required.
|
||||
* @attr {number} maxlength - Maximum number of characters allowed.
|
||||
* @attr {("none"|"vertical"|"horizontal"|"both"|"auto")} resize - Controls the textarea's resize behavior. Defaults to `"none"`.
|
||||
* @attr {boolean} auto-validate - Validates the textarea on blur instead of on form submit.
|
||||
* @attr {boolean} invalid - Marks the textarea as invalid.
|
||||
* @attr {string} validation-message - Custom validation message shown when the textarea is invalid.
|
||||
*/
|
||||
@customElement("ha-textarea")
|
||||
export class HaTextArea extends TextAreaBase {
|
||||
@property({ type: Boolean, reflect: true }) autogrow = false;
|
||||
export class HaTextArea extends WaInputMixin(LitElement) {
|
||||
@property({ type: Number })
|
||||
public rows?: number;
|
||||
|
||||
updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (this.autogrow && changedProperties.has("value")) {
|
||||
this.mdcRoot.dataset.value = this.value + '=\u200B"'; // add a zero-width space to correctly wrap
|
||||
@property()
|
||||
public resize: "none" | "vertical" | "horizontal" | "both" | "auto" = "none";
|
||||
|
||||
@query("wa-textarea")
|
||||
private _textarea?: WaTextarea;
|
||||
|
||||
private readonly _hasSlotController = new HasSlotController(
|
||||
this,
|
||||
"label",
|
||||
"hint"
|
||||
);
|
||||
|
||||
protected get _formControl(): WaTextarea | undefined {
|
||||
return this._textarea;
|
||||
}
|
||||
|
||||
protected readonly _requiredMarkerCSSVar = "--ha-textarea-required-marker";
|
||||
|
||||
/** Programmatically toggle focus styling (used by ha-date-range-picker). */
|
||||
public setFocused(focused: boolean): void {
|
||||
if (focused) {
|
||||
this.toggleAttribute("focused", true);
|
||||
} else {
|
||||
this.removeAttribute("focused");
|
||||
}
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
textfieldStyles,
|
||||
textareaStyles,
|
||||
protected render() {
|
||||
const hasLabelSlot = this.label
|
||||
? false
|
||||
: this._hasSlotController.test("label");
|
||||
|
||||
const hasHintSlot = this.hint
|
||||
? false
|
||||
: this._hasSlotController.test("hint");
|
||||
|
||||
return html`
|
||||
<wa-textarea
|
||||
.value=${this.value ?? null}
|
||||
.placeholder=${this.placeholder}
|
||||
.readonly=${this.readonly}
|
||||
.required=${this.required}
|
||||
.rows=${this.rows ?? 4}
|
||||
.resize=${this.resize}
|
||||
.disabled=${this.disabled}
|
||||
name=${ifDefined(this.name)}
|
||||
autocapitalize=${ifDefined(this.autocapitalize || undefined)}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
.autofocus=${this.autofocus}
|
||||
.spellcheck=${this.spellcheck}
|
||||
inputmode=${ifDefined(this.inputmode || undefined)}
|
||||
enterkeyhint=${ifDefined(this.enterkeyhint || undefined)}
|
||||
minlength=${ifDefined(this.minlength)}
|
||||
maxlength=${ifDefined(this.maxlength)}
|
||||
class=${classMap({
|
||||
input: true,
|
||||
invalid: this.invalid || this._invalid,
|
||||
"label-raised":
|
||||
(this.value !== undefined && this.value !== "") ||
|
||||
(this.label && this.placeholder),
|
||||
"no-label": !this.label,
|
||||
"hint-hidden":
|
||||
!this.hint &&
|
||||
!hasHintSlot &&
|
||||
!this.required &&
|
||||
!this._invalid &&
|
||||
!this.invalid,
|
||||
})}
|
||||
@input=${this._handleInput}
|
||||
@change=${this._handleChange}
|
||||
@blur=${this._handleBlur}
|
||||
@wa-invalid=${this._handleInvalid}
|
||||
exportparts="base:wa-base, hint:wa-hint, textarea:wa-textarea"
|
||||
>
|
||||
${this.label || hasLabelSlot
|
||||
? html`<slot name="label" slot="label"
|
||||
>${this.label
|
||||
? this._renderLabel(this.label, this.required)
|
||||
: nothing}</slot
|
||||
>`
|
||||
: nothing}
|
||||
<div
|
||||
slot="hint"
|
||||
class=${classMap({
|
||||
error: this.invalid || this._invalid,
|
||||
})}
|
||||
role=${ifDefined(this.invalid || this._invalid ? "alert" : undefined)}
|
||||
aria-live="polite"
|
||||
>
|
||||
${this._invalid || this.invalid
|
||||
? this.validationMessage || this._textarea?.validationMessage
|
||||
: this.hint ||
|
||||
(hasHintSlot ? html`<slot name="hint"></slot>` : nothing)}
|
||||
</div>
|
||||
</wa-textarea>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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`<slot name="label" slot="label"
|
||||
>${this._renderLabel(this.label, this.required)}</slot
|
||||
>${this.label
|
||||
? this._renderLabel(this.label, this.required)
|
||||
: nothing}</slot
|
||||
>`
|
||||
: nothing}
|
||||
<slot name="start" slot="start" @slotchange=${this._syncStartSlotWidth}>
|
||||
@@ -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 {
|
||||
|
||||
353
src/components/input/wa-input-mixin.ts
Normal file
353
src/components/input/wa-input-mixin.ts
Normal file
@@ -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 = <T extends Constructor<LitElement>>(
|
||||
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);
|
||||
}
|
||||
`;
|
||||
@@ -58,7 +58,7 @@ class BrowseMediaTTS extends LitElement {
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<ha-textarea
|
||||
autogrow
|
||||
resize="auto"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.media-browser.tts.message"
|
||||
)}
|
||||
@@ -200,7 +200,7 @@ class BrowseMediaTTS extends LitElement {
|
||||
}
|
||||
|
||||
private async _ttsClicked(): Promise<void> {
|
||||
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();
|
||||
|
||||
@@ -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}
|
||||
></ha-textarea>`
|
||||
: nothing}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "../../../../../components/ha-textarea";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
<ha-textarea
|
||||
autogrow
|
||||
resize="auto"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.assist.sentences"
|
||||
)}
|
||||
|
||||
@@ -3,19 +3,21 @@ import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
|
||||
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import { supportsMarkdownHelper } from "../../common/translations/markdown_support";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-checkbox";
|
||||
import "../../components/ha-date-input";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-textarea";
|
||||
import "../../components/ha-textfield";
|
||||
import "../../components/ha-time-input";
|
||||
import "../../components/ha-dialog";
|
||||
import type { TodoItem } from "../../data/todo";
|
||||
import {
|
||||
TodoItemStatus,
|
||||
@@ -28,8 +30,6 @@ import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { TodoItemEditDialogParams } from "./show-dialog-todo-item-editor";
|
||||
import { supportsMarkdownHelper } from "../../common/translations/markdown_support";
|
||||
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
|
||||
|
||||
@customElement("dialog-todo-item-editor")
|
||||
class DialogTodoItemEditor extends LitElement {
|
||||
@@ -179,10 +179,10 @@ class DialogTodoItemEditor extends LitElement {
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.todo.item.description"
|
||||
)}
|
||||
.helper=${supportsMarkdownHelper(this.hass.localize)}
|
||||
.hint=${supportsMarkdownHelper(this.hass.localize)}
|
||||
.value=${this._description}
|
||||
@input=${this._handleDescriptionChanged}
|
||||
autogrow
|
||||
resize="auto"
|
||||
.disabled=${!canUpdate}
|
||||
></ha-textarea>`
|
||||
: nothing}
|
||||
|
||||
Reference in New Issue
Block a user