Compare commits

...
2 Commits
4 changed files with 384 additions and 8 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ export class HaAuthFlow extends LitElement {
width: 100%;
}
</style>
<form>${this._renderForm()}</form>
<form @submit=${this._handleSubmit}>${this._renderForm()}</form>
`;
}
+56 -7
View File
@@ -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>
`;
}
}
+250
View File
@@ -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;
}
}
+77
View File
@@ -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"));
});
});