/**
* Tests for ArsInput
* @vi-environment jsdom
*/
import { beforeEach, describe, expect, it } from "vitest";
import { ArsInput } from "./ars-input.js";
describe("ArsInput", () => {
let element: ArsInput;
beforeEach(() => {
document.body.innerHTML = "";
element = new ArsInput();
});
// --- Registration ---
it("registers the ars-input custom element", () => {
expect(customElements.get("ars-input")).toBe(ArsInput);
});
it("creates a shadow root on construction", () => {
expect(element.shadowRoot).toBeTruthy();
});
// --- Default rendering ---
it("renders a native input element inside shadow DOM", () => {
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input).toBeTruthy();
});
it("defaults to text type", () => {
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.getAttribute("type")).toBe("text");
});
// --- Type attribute ---
it("reflects the type attribute to the native input", () => {
element.setAttribute("type", "email");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.getAttribute("type")).toBe("email");
});
it("supports all valid input types", () => {
document.body.appendChild(element);
for (const type of ["text", "number", "email", "password", "search", "url", "tel"] as const) {
element.inputType = type;
const input = element.shadowRoot?.querySelector("input");
expect(input?.getAttribute("type")).toBe(type);
}
});
// --- Label ---
it("renders a label element when label attribute is set", () => {
element.setAttribute("label", "Email address");
document.body.appendChild(element);
const label = element.shadowRoot?.querySelector("label");
expect(label).toBeTruthy();
expect(label?.textContent).toContain("Email address");
});
it("does not render a label when label attribute is not set", () => {
document.body.appendChild(element);
const label = element.shadowRoot?.querySelector("label");
expect(label).toBeNull();
});
it("links label to input via for attribute", () => {
element.setAttribute("label", "Name");
document.body.appendChild(element);
const label = element.shadowRoot?.querySelector("label");
const input = element.shadowRoot?.querySelector("input");
expect(label?.getAttribute("for")).toBe(input?.getAttribute("id"));
});
// --- Value ---
it("sets the initial value from attribute", () => {
element.setAttribute("value", "hello");
document.body.appendChild(element);
expect(element.value).toBe("hello");
});
it("updates value via property setter", () => {
document.body.appendChild(element);
element.value = "world";
const input = element.shadowRoot?.querySelector("input");
expect(input?.value).toBe("world");
});
// --- Placeholder ---
it("renders the placeholder attribute", () => {
element.setAttribute("placeholder", "Enter text...");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.getAttribute("placeholder")).toBe("Enter text...");
});
// --- Disabled state ---
it("disables the native input when disabled attribute is set", () => {
element.setAttribute("disabled", "");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.hasAttribute("disabled")).toBe(true);
});
it("reflects disabled via property setter", () => {
document.body.appendChild(element);
element.disabled = true;
expect(element.hasAttribute("disabled")).toBe(true);
});
// --- Readonly state ---
it("sets readonly on the native input", () => {
element.setAttribute("readonly", "");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.hasAttribute("readonly")).toBe(true);
});
// --- Error state ---
it("renders error message when error attribute is set", () => {
element.setAttribute("error", "This field is required");
document.body.appendChild(element);
const errorEl = element.shadowRoot?.querySelector(".error-msg");
expect(errorEl).toBeTruthy();
expect(errorEl?.textContent).toContain("This field is required");
});
it("sets aria-invalid on input when error is present", () => {
element.setAttribute("error", "Invalid email");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.getAttribute("aria-invalid")).toBe("true");
});
it("sets aria-describedby pointing to error element", () => {
element.setAttribute("error", "Bad value");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
const errorEl = element.shadowRoot?.querySelector(".error-msg");
expect(input?.getAttribute("aria-describedby")).toBe(errorEl?.getAttribute("id"));
});
it("does not show error when error attribute is not set", () => {
document.body.appendChild(element);
expect(element.shadowRoot?.querySelector(".error-msg")).toBeNull();
});
it("updates error via property setter", () => {
document.body.appendChild(element);
element.error = "Required field";
expect(element.shadowRoot?.querySelector(".error-msg")?.textContent).toContain("Required field");
element.error = "";
expect(element.shadowRoot?.querySelector(".error-msg")).toBeNull();
});
// --- Clearable ---
it("shows clear button when clearable and value is non-empty", () => {
element.setAttribute("clearable", "");
element.setAttribute("value", "something");
document.body.appendChild(element);
expect(element.shadowRoot?.querySelector(".clear-btn")).toBeTruthy();
});
it("hides clear button when value is empty", () => {
element.setAttribute("clearable", "");
document.body.appendChild(element);
expect(element.shadowRoot?.querySelector(".clear-btn")).toBeNull();
});
it("clears value and emits ars-input:clear when clear button is clicked", () => {
element.setAttribute("clearable", "");
element.setAttribute("value", "test");
document.body.appendChild(element);
const events: any[] = [];
element.addEventListener("ars-input:clear", (e) => events.push(e));
element.shadowRoot?.querySelector(".clear-btn")?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
expect(element.shadowRoot?.querySelector(".clear-btn")).toBeNull();
expect(events.length).toBe(1);
});
// --- Required ---
it("forwards required attribute to native input", () => {
element.setAttribute("required", "");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.hasAttribute("required")).toBe(true);
});
// --- Constraint forwarding ---
it("forwards min, max, step, pattern to native input", () => {
element.setAttribute("type", "number");
element.setAttribute("min", "0");
element.setAttribute("max", "100");
element.setAttribute("step", "5");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.getAttribute("min")).toBe("0");
expect(input?.getAttribute("max")).toBe("100");
expect(input?.getAttribute("step")).toBe("5");
});
it("forwards pattern attribute", () => {
element.setAttribute("pattern", "[A-Z]+");
document.body.appendChild(element);
const input = element.shadowRoot?.querySelector("input");
expect(input?.getAttribute("pattern")).toBe("[A-Z]+");
});
// --- Events ---
it("emits ars-input:input on keystroke", () => {
document.body.appendChild(element);
const events: string[] = [];
element.addEventListener("ars-input:input", (e) => {
events.push((e as CustomEvent).detail.value);
});
const input = element.shadowRoot!.querySelector("input")!;
input.value = "a";
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(events).toEqual(["a"]);
});
it("emits ars-input:change on native change", () => {
document.body.appendChild(element);
const events: string[] = [];
element.addEventListener("ars-input:change", (e) => {
events.push((e as CustomEvent).detail.value);
});
const input = element.shadowRoot!.querySelector("input")!;
input.value = "final";
input.dispatchEvent(new Event("change", { bubbles: true }));
expect(events).toEqual(["final"]);
});
it("emits ars-input:change on Enter key", () => {
document.body.appendChild(element);
const events: string[] = [];
element.addEventListener("ars-input:change", (e) => {
events.push((e as CustomEvent).detail.value);
});
const input = element.shadowRoot!.querySelector("input")!;
input.value = "entered";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(events).toEqual(["entered"]);
});
// --- Slots ---
it("provides a prefix slot", () => {
document.body.appendChild(element);
const slot = element.shadowRoot?.querySelector('slot[name="prefix"]');
expect(slot).toBeTruthy();
});
it("provides a suffix slot", () => {
document.body.appendChild(element);
const slot = element.shadowRoot?.querySelector('slot[name="suffix"]');
expect(slot).toBeTruthy();
});
// --- Observed attributes ---
it("observes the expected attributes", () => {
const attrs = ArsInput.observedAttributes;
for (const attr of ["type", "value", "placeholder", "label", "error", "disabled", "readonly", "clearable", "min", "max", "step", "pattern", "required"]) {
expect(attrs).toContain(attr);
}
});
// --- CSS token usage ---
it("uses design token variables in styles", () => {
document.body.appendChild(element);
const styles = element.shadowRoot?.querySelector("style")?.textContent ?? "";
expect(styles).toContain("--arswc-color-border");
expect(styles).toContain("--arswc-color-danger");
expect(styles).toContain("--arswc-focus-ring");
expect(styles).toContain("--arswc-font-size-sm");
expect(styles).toContain("--arswc-font-size-md");
});
// --- XSS protection ---
it("escapes HTML in label and error messages", () => {
element.setAttribute("label", "");
element.setAttribute("error", "
");
document.body.appendChild(element);
const label = element.shadowRoot?.querySelector("label");
expect(label?.textContent).toContain("");
expect(label?.querySelector("script")).toBeNull();
const errorEl = element.shadowRoot?.querySelector(".error-msg");
expect(errorEl?.textContent).toContain("
");
expect(errorEl?.querySelector("img")).toBeNull();
});
});