Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions packages/dom/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Assertion, AssertionError } from "@assertive-ts/core";
import equal from "fast-deep-equal";

import { getAccessibleDescription, isValidAriaPressed } from "./helpers/accessibility";
import { isButtonElement, isElementEmpty } from "./helpers/dom";
import { getAccessibleDescription, isValidAriaChecked, isValidAriaPressed } from "./helpers/accessibility";
import { isButtonElement, isCheckableInput, isCheckboxInput, isElementEmpty } from "./helpers/dom";
import { getExpectedAndReceivedStyles } from "./helpers/styles";

export class ElementAssertion<T extends Element> extends Assertion<T> {
Expand Down Expand Up @@ -434,6 +434,88 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
});
}

/**
* Asserts that the element is checked.
*
* Valid for `<input type="checkbox">`, `<input type="radio">`, or elements
* with a valid `aria-checked` attribute ("true" or "false").
*
* @example
* // Native checkbox
* expect(element).toBeChecked();
* expect(element).not.toBeChecked();
*
* // ARIA checkbox
* expect(element).toBeChecked(); // when aria-checked="true"
*
* @returns the assertion instance.
*/
public toBeChecked(): this {
const isNativeCheckable = isCheckableInput(this.actual);
const isAriaCheckable = isValidAriaChecked(this.actual)
&& ["true", "false"].includes(this.actual.getAttribute("aria-checked") ?? "");
if (!isNativeCheckable && !isAriaCheckable) {
throw new Error(
"Only checkbox/radio inputs or valid aria-checked elements can be used with .toBeChecked()",
);
}
const isChecked = isNativeCheckable
? (this.actual as unknown as HTMLInputElement).checked
: this.actual.getAttribute("aria-checked") === "true";
const error = new AssertionError({
actual: this.actual,
message: "Expected the element to be checked",
});
const invertedError = new AssertionError({
actual: this.actual,
message: "Expected the element to NOT be checked",
});
return this.execute({
assertWhen: isChecked,
error,
invertedError,
});
}

/**
* Asserts that the element is partially checked (indeterminate).
*
* Valid for `<input type="checkbox">` or elements with `role="checkbox"`
* and `aria-checked="mixed"`.
*
* @example
* expect(element).toBePartiallyChecked();
* expect(element).not.toBePartiallyChecked();
*
* @returns the assertion instance.
*/
public toBePartiallyChecked(): this {
const isNativeCheckbox = isCheckboxInput(this.actual);
const isAriaCheckbox = this.actual.getAttribute("role") === "checkbox";
if (!isNativeCheckbox && !isAriaCheckbox) {
throw new Error(
"Only checkbox inputs or checkbox-role elements can be used with .toBePartiallyChecked()",
);
}
const isPartiallyChecked = isNativeCheckbox
? (this.actual as unknown as HTMLInputElement).indeterminate
|| this.actual.getAttribute("aria-checked") === "mixed"
: this.actual.getAttribute("aria-checked") === "mixed";
const error = new AssertionError({
actual: this.actual,
message: "Expected the element to be partially checked",
});
const invertedError = new AssertionError({
actual: this.actual,
message: "Expected the element to NOT be partially checked",
});
return this.execute({
assertWhen: isPartiallyChecked,
error,
invertedError,
});
}

/**
* Helper method to assert the presence or absence of class names.
*
Expand Down
5 changes: 5 additions & 0 deletions packages/dom/src/lib/helpers/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ export function isValidAriaPressed(element: Element): boolean {
const pressedAttribute = element.getAttribute("aria-pressed");
return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute);
}

export function isValidAriaChecked(element: Element): boolean {
const checkedAttribute = element.getAttribute("aria-checked");
return checkedAttribute !== null && ["true", "false", "mixed"].includes(checkedAttribute);
}
12 changes: 12 additions & 0 deletions packages/dom/src/lib/helpers/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ export function isButtonElement(element: Element): boolean {

return isNativeButton || hasButtonRole;
}

export function isCheckableInput(element: Element): boolean {
const tagName = element.tagName.toLowerCase();
const type = element.getAttribute("type");
return tagName === "input" && (type === "checkbox" || type === "radio");
}

export function isCheckboxInput(element: Element): boolean {
const tagName = element.tagName.toLowerCase();
const type = element.getAttribute("type");
return tagName === "input" && type === "checkbox";
}
228 changes: 228 additions & 0 deletions packages/dom/test/unit/lib/ElementAssertion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render } from "@testing-library/react";

import { ElementAssertion } from "../../../src/lib/ElementAssertion";

import { CheckedTestComponent } from "./fixtures/CheckedTestComponent";
import { HaveClassTest } from "./fixtures/HaveClassTest";
import { NestedElementsTest } from "./fixtures/NestedElementsTest";
import { PressedTestComponent } from "./fixtures/PressedTestComponent";
Expand Down Expand Up @@ -823,4 +824,231 @@ describe("[Unit] ElementAssertion.test.ts", () => {
});
});
});

describe(".toBeChecked", () => {
context("when the element is a native checkbox", () => {
context("when the checkbox is checked", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const checkbox = getByTestId("checkbox-checked");
const test = new ElementAssertion(checkbox);

expect(test.toBeChecked()).toBeEqual(test);

expect(() => test.not.toBeChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to NOT be checked");
});
});

context("when the checkbox is not checked", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const checkbox = getByTestId("checkbox-unchecked");
const test = new ElementAssertion(checkbox);

expect(() => test.toBeChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to be checked");

expect(test.not.toBeChecked()).toBeEqual(test);
});
});
});

context("when the element is a native radio", () => {
context("when the radio is checked", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const radio = getByTestId("radio-checked");
const test = new ElementAssertion(radio);

expect(test.toBeChecked()).toBeEqual(test);

expect(() => test.not.toBeChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to NOT be checked");
});
});

context("when the radio is not checked", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const radio = getByTestId("radio-unchecked");
const test = new ElementAssertion(radio);

expect(() => test.toBeChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to be checked");

expect(test.not.toBeChecked()).toBeEqual(test);
});
});
});

context("when the element has role=\"checkbox\" with aria-checked", () => {
context("when aria-checked is \"true\"", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("aria-checkbox-checked");
const test = new ElementAssertion(div);

expect(test.toBeChecked()).toBeEqual(test);

expect(() => test.not.toBeChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to NOT be checked");
});
});

context("when aria-checked is \"false\"", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("aria-checkbox-unchecked");
const test = new ElementAssertion(div);

expect(() => test.toBeChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to be checked");

expect(test.not.toBeChecked()).toBeEqual(test);
});
});
});

context("when the element has role=\"switch\" with aria-checked", () => {
context("when aria-checked is \"true\"", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("aria-switch-checked");
const test = new ElementAssertion(div);

expect(test.toBeChecked()).toBeEqual(test);

expect(() => test.not.toBeChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to NOT be checked");
});
});

context("when aria-checked is \"false\"", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("aria-switch-unchecked");
const test = new ElementAssertion(div);

expect(() => test.toBeChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to be checked");

expect(test.not.toBeChecked()).toBeEqual(test);
});
});
});

context("when the element is not a valid checkable element", () => {
it("throws a plain Error", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("non-checkable-element");
const test = new ElementAssertion(div);

expect(() => test.toBeChecked()).toThrowError(Error);
});
});
});

describe(".toBePartiallyChecked", () => {
context("when the element is a native checkbox", () => {
context("when the checkbox is indeterminate", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const checkbox = getByTestId("checkbox-indeterminate") as HTMLInputElement;
checkbox.indeterminate = true;
const test = new ElementAssertion(checkbox);

expect(test.toBePartiallyChecked()).toBeEqual(test);

expect(() => test.not.toBePartiallyChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to NOT be partially checked");
});
});

context("when the checkbox is not indeterminate", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const checkbox = getByTestId("checkbox-checked");
const test = new ElementAssertion(checkbox);

expect(() => test.toBePartiallyChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to be partially checked");

expect(test.not.toBePartiallyChecked()).toBeEqual(test);
});
});
});

context("when the element has role=\"checkbox\" with aria-checked", () => {
context("when aria-checked is \"mixed\"", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("aria-checkbox-mixed");
const test = new ElementAssertion(div);

expect(test.toBePartiallyChecked()).toBeEqual(test);

expect(() => test.not.toBePartiallyChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to NOT be partially checked");
});
});

context("when aria-checked is \"true\"", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("aria-checkbox-checked");
const test = new ElementAssertion(div);

expect(() => test.toBePartiallyChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to be partially checked");

expect(test.not.toBePartiallyChecked()).toBeEqual(test);
});
});

context("when aria-checked is \"false\"", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("aria-checkbox-unchecked");
const test = new ElementAssertion(div);

expect(() => test.toBePartiallyChecked())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to be partially checked");

expect(test.not.toBePartiallyChecked()).toBeEqual(test);
});
});
});

context("when the element is not a valid checkbox element", () => {
it("throws a plain Error for a plain div", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const div = getByTestId("non-checkable-element");
const test = new ElementAssertion(div);

expect(() => test.toBePartiallyChecked()).toThrowError(Error);
});

it("throws a plain Error for a radio input", () => {
const { getByTestId } = render(<CheckedTestComponent />);
const radio = getByTestId("radio-checked");
const test = new ElementAssertion(radio);

expect(() => test.toBePartiallyChecked()).toThrowError(Error);
});
});
});
});
34 changes: 34 additions & 0 deletions packages/dom/test/unit/lib/fixtures/CheckedTestComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ReactElement } from "react";

export function CheckedTestComponent(): ReactElement {
return (
<div>
{/* Native checkbox variants */}
<input data-testid="checkbox-checked" type="checkbox" defaultChecked={true} />
<input data-testid="checkbox-unchecked" type="checkbox" />

{/* Native radio variants */}
<input data-testid="radio-checked" type="radio" defaultChecked={true} />
<input data-testid="radio-unchecked" type="radio" />

{/* ARIA checkbox variants */}
<div data-testid="aria-checkbox-checked" role="checkbox" aria-checked="true">{"Checked"}</div>
<div data-testid="aria-checkbox-unchecked" role="checkbox" aria-checked="false">{"Unchecked"}</div>
<div data-testid="aria-checkbox-mixed" role="checkbox" aria-checked="mixed">{"Mixed"}</div>

{/* ARIA radio variants */}
<div data-testid="aria-radio-checked" role="radio" aria-checked="true">{"Checked"}</div>
<div data-testid="aria-radio-unchecked" role="radio" aria-checked="false">{"Unchecked"}</div>

{/* ARIA switch variants */}
<div data-testid="aria-switch-checked" role="switch" aria-checked="true">{"On"}</div>
<div data-testid="aria-switch-unchecked" role="switch" aria-checked="false">{"Off"}</div>

{/* Element for indeterminate test (set programmatically in test) */}
<input data-testid="checkbox-indeterminate" type="checkbox" />

{/* Invalid elements */}
<div data-testid="non-checkable-element">{"Not checkable"}</div>
</div>
);
}
Loading