mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-22 13:04:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4d5a07e71 | ||
|
|
f92926cb3b |
@@ -127,7 +127,7 @@ export class HaAuthFlow extends LitElement {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<form>${this._renderForm()}</form>
|
||||
<form @submit=${this._handleSubmit}>${this._renderForm()}</form>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,66 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { HaFormString } from "../components/ha-form/ha-form-string";
|
||||
import "../components/ha-icon-button";
|
||||
import "../components/input/ha-input";
|
||||
import "./ha-auth-textfield";
|
||||
import type { HaAuthTextField } from "./ha-auth-textfield";
|
||||
|
||||
@customElement("ha-auth-form-string")
|
||||
export class HaAuthFormString extends HaFormString {
|
||||
protected createRenderRoot() {
|
||||
@query("ha-auth-textfield") private _textfield?: HaAuthTextField;
|
||||
|
||||
protected override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.position = "relative";
|
||||
public override focus(): void {
|
||||
this._textfield?.focus();
|
||||
}
|
||||
|
||||
public override reportValidity(): boolean {
|
||||
const textfield = this._textfield;
|
||||
if (!textfield) {
|
||||
return true;
|
||||
}
|
||||
const valid = textfield.reportValidity();
|
||||
// Adopt a value a password manager wrote without an event reaching us.
|
||||
const value = textfield.value ?? "";
|
||||
if ((this.data ?? "") !== value) {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
return html`
|
||||
<ha-auth-textfield
|
||||
.passwordToggle=${this.isPassword}
|
||||
.type=${!this.isPassword ? this.stringType : "password"}
|
||||
.label=${this.label}
|
||||
.value=${this.data || ""}
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${!!this.schema.required}
|
||||
.autoValidate=${!!this.schema.required}
|
||||
.name=${this.schema.name}
|
||||
.autofocus=${!!this.schema.autofocus}
|
||||
.autocomplete=${this.schema.autocomplete}
|
||||
.validationMessage=${
|
||||
this.schema.required
|
||||
? this.localize?.("ui.panel.page-authorize.form.error_required")
|
||||
: undefined
|
||||
}
|
||||
.showPasswordLabel=${this.localize?.(
|
||||
"ui.panel.page-authorize.form.show_password"
|
||||
)}
|
||||
.hidePasswordLabel=${this.localize?.(
|
||||
"ui.panel.page-authorize.form.hide_password"
|
||||
)}
|
||||
@input=${this._valueChanged}
|
||||
@change=${this._valueChanged}
|
||||
></ha-auth-textfield>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable lit/prefer-static-styles */
|
||||
import { mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import "../components/ha-icon-button";
|
||||
import { WaInputMixin } from "../components/input/wa-input-mixin";
|
||||
|
||||
/**
|
||||
* Text field for the login page. It renders the native input in the light DOM
|
||||
* so browsers and password manager extensions can find it (#51620).
|
||||
*/
|
||||
@customElement("ha-auth-textfield")
|
||||
export class HaAuthTextField extends WaInputMixin(LitElement) {
|
||||
@property() public type: "text" | "password" | "email" | "url" = "text";
|
||||
|
||||
@property({ type: Boolean, attribute: "password-toggle" })
|
||||
public passwordToggle = false;
|
||||
|
||||
@property({ attribute: false }) public showPasswordLabel?: string;
|
||||
|
||||
@property({ attribute: false }) public hidePasswordLabel?: string;
|
||||
|
||||
@state() private _passwordVisible = false;
|
||||
|
||||
@query("input") private _input?: HTMLInputElement;
|
||||
|
||||
protected override get _formControl(): HTMLInputElement | undefined {
|
||||
return this._input;
|
||||
}
|
||||
|
||||
protected override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override firstUpdated(changedProps: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProps);
|
||||
if (this.autofocus) {
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public override focus(): void {
|
||||
this._input?.focus();
|
||||
}
|
||||
|
||||
public override checkValidity(): boolean {
|
||||
return this._input?.checkValidity() ?? true;
|
||||
}
|
||||
|
||||
public override reportValidity(): boolean {
|
||||
// Adopt a value a password manager wrote to the input without events.
|
||||
this._handleInput();
|
||||
return super.reportValidity();
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
const invalid = this.invalid || this._invalid;
|
||||
const hintId = this.name ? `${this.name}-hint` : undefined;
|
||||
|
||||
// The blank placeholder lets :placeholder-shown raise the label when a
|
||||
// password manager fills the field without firing events.
|
||||
return html`
|
||||
<style>
|
||||
ha-auth-textfield {
|
||||
display: block;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
text-align: start;
|
||||
}
|
||||
ha-auth-textfield .base {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
padding: 0 var(--ha-space-4);
|
||||
background-color: var(--ha-color-form-background);
|
||||
border-radius: var(--ha-border-radius-sm) var(--ha-border-radius-sm)
|
||||
var(--ha-border-radius-square) var(--ha-border-radius-square);
|
||||
cursor: text;
|
||||
transition: background-color var(--wa-transition-normal) ease-in-out;
|
||||
}
|
||||
ha-auth-textfield .base:hover {
|
||||
background-color: var(--ha-color-form-background-hover);
|
||||
}
|
||||
ha-auth-textfield .base.disabled {
|
||||
background-color: var(--ha-color-form-background-disabled);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
ha-auth-textfield .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;
|
||||
}
|
||||
ha-auth-textfield .base:focus-within::after {
|
||||
height: 2px;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
ha-auth-textfield .base.invalid:not(.disabled)::after {
|
||||
background-color: var(--ha-color-border-danger-normal);
|
||||
}
|
||||
ha-auth-textfield label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
padding: var(--ha-space-5) var(--ha-space-4) 0;
|
||||
pointer-events: none;
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--secondary-text-color);
|
||||
transition: all var(--wa-transition-normal) ease-in-out;
|
||||
}
|
||||
ha-auth-textfield input:focus + label,
|
||||
ha-auth-textfield input:not(:placeholder-shown) + label {
|
||||
padding-top: var(--ha-space-3);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
ha-auth-textfield .base:focus-within label {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
ha-auth-textfield .base.invalid:not(.disabled) label {
|
||||
color: var(--ha-color-fill-danger-loud-resting);
|
||||
}
|
||||
ha-auth-textfield .base.disabled label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
ha-auth-textfield input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: var(--ha-space-3) 0 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
-webkit-appearance: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
ha-auth-textfield input:-webkit-autofill,
|
||||
ha-auth-textfield input:-webkit-autofill:hover,
|
||||
ha-auth-textfield input:-webkit-autofill:focus,
|
||||
ha-auth-textfield input:-webkit-autofill:active {
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: var(--primary-text-color);
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
caret-color: var(--primary-text-color);
|
||||
}
|
||||
ha-auth-textfield input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
ha-auth-textfield ha-icon-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
ha-auth-textfield .hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--ha-space-5);
|
||||
margin-inline-start: var(--ha-space-3);
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
ha-auth-textfield .hint.error {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
}
|
||||
</style>
|
||||
<div class=${classMap({ base: true, invalid, disabled: this.disabled })}>
|
||||
<input
|
||||
id=${ifDefined(this.name)}
|
||||
name=${ifDefined(this.name)}
|
||||
type=${
|
||||
this.type === "password" && this._passwordVisible
|
||||
? "text"
|
||||
: this.type
|
||||
}
|
||||
placeholder=" "
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
.value=${this.value ?? ""}
|
||||
aria-describedby=${ifDefined(hintId)}
|
||||
aria-invalid=${ifDefined(invalid ? "true" : undefined)}
|
||||
@input=${this._handleInput}
|
||||
@change=${this._handleChange}
|
||||
@blur=${this._handleBlur}
|
||||
/>
|
||||
<label for=${ifDefined(this.name)}
|
||||
>${this._renderLabel(this.label ?? "", this.required)}</label
|
||||
>
|
||||
${
|
||||
this.passwordToggle && !this.disabled
|
||||
? html`<ha-icon-button
|
||||
.path=${this._passwordVisible ? mdiEyeOff : mdiEye}
|
||||
.label=${
|
||||
this._passwordVisible
|
||||
? this.hidePasswordLabel
|
||||
: this.showPasswordLabel
|
||||
}
|
||||
@click=${this._togglePasswordVisibility}
|
||||
@keypress=${stopPropagation}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
id=${ifDefined(hintId)}
|
||||
class=${classMap({ hint: true, error: invalid })}
|
||||
role=${ifDefined(invalid ? "alert" : undefined)}
|
||||
aria-live="polite"
|
||||
>
|
||||
${
|
||||
invalid
|
||||
? this.validationMessage || this._input?.validationMessage
|
||||
: this.hint
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _togglePasswordVisibility(): void {
|
||||
this._passwordVisible = !this._passwordVisible;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-auth-textfield": HaAuthTextField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import "../../src/auth/ha-auth-form-string";
|
||||
import type { HaAuthFormString } from "../../src/auth/ha-auth-form-string";
|
||||
import type { LocalizeFunc } from "../../src/common/translations/localize";
|
||||
import type { HaFormStringSchema } from "../../src/components/ha-form/types";
|
||||
|
||||
const USERNAME_SCHEMA: HaFormStringSchema = {
|
||||
name: "username",
|
||||
type: "string",
|
||||
required: true,
|
||||
autocomplete: "username",
|
||||
};
|
||||
|
||||
let forms: HTMLFormElement[] = [];
|
||||
|
||||
const mount = async (
|
||||
schema: HaFormStringSchema,
|
||||
data?: string
|
||||
): Promise<HaAuthFormString> => {
|
||||
const form = document.createElement("form");
|
||||
const el = document.createElement("ha-auth-form-string");
|
||||
el.schema = schema;
|
||||
el.data = data as string;
|
||||
el.label = "Username";
|
||||
el.localize = ((key: string) => key) as unknown as LocalizeFunc;
|
||||
form.append(el);
|
||||
document.body.append(form);
|
||||
forms.push(form);
|
||||
await el.updateComplete;
|
||||
await el.querySelector("ha-auth-textfield")!.updateComplete;
|
||||
return el;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
forms.forEach((form) => form.remove());
|
||||
forms = [];
|
||||
});
|
||||
|
||||
describe("ha-auth-form-string", () => {
|
||||
// Password managers only find login fields whose native input is in the
|
||||
// light DOM (#51620), so this must not silently move into a shadow root.
|
||||
it("renders the native input in the light DOM", async () => {
|
||||
const el = await mount(USERNAME_SCHEMA);
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'input[name="username"]'
|
||||
);
|
||||
expect(input).not.toBeNull();
|
||||
expect(el.shadowRoot).toBeNull();
|
||||
expect(input!.getAttribute("autocomplete")).toBe("username");
|
||||
expect(input!.id).toBe("username");
|
||||
expect(el.querySelector("label")!.htmlFor).toBe("username");
|
||||
expect(el.closest("form")!.elements.namedItem("username")).toBe(input);
|
||||
});
|
||||
|
||||
it("submits a value written to the native input without events", async () => {
|
||||
const el = await mount(USERNAME_SCHEMA);
|
||||
const received: string[] = [];
|
||||
el.addEventListener("value-changed", (ev) => {
|
||||
received.push((ev as CustomEvent).detail.value);
|
||||
});
|
||||
|
||||
el.querySelector("input")!.value = "admin";
|
||||
|
||||
expect(el.reportValidity()).toBe(true);
|
||||
expect(received).toEqual(["admin"]);
|
||||
});
|
||||
|
||||
it("reports an empty required field and can focus it", async () => {
|
||||
const el = await mount(USERNAME_SCHEMA);
|
||||
|
||||
expect(el.reportValidity()).toBe(false);
|
||||
|
||||
el.focus();
|
||||
expect(document.activeElement).toBe(el.querySelector("input"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user