Skip to content
Merged
73 changes: 71 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 } from "./helpers/accessibility";
import { isElementEmpty } from "./helpers/dom";
import { getAccessibleDescription, isValidAriaPressed } from "./helpers/accessibility";
import { isButtonElement, isElementEmpty } from "./helpers/dom";
import { getExpectedAndReceivedStyles } from "./helpers/styles";

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

/**
* Asserts that the element is a pressed button.
*
Comment thread
KeylaMunnoz marked this conversation as resolved.
* @returns the assertion instance.
*/

public toBePressed(): this {
if (!isButtonElement(this.actual) || !isValidAriaPressed(this.actual)) {
throw new Error(
'.toBePressed() requires a button, input[type="button"], or role="button" with valid aria-pressed',
Comment thread
KeylaMunnoz marked this conversation as resolved.
Outdated
);
}

const pressedAttribute = this.actual.getAttribute("aria-pressed");
Comment thread
KeylaMunnoz marked this conversation as resolved.
Outdated
const isPressed = pressedAttribute === "true";

const error = new AssertionError({
actual: pressedAttribute,
expected: "true",
message: `Expected the element to be pressed, but received aria-pressed="${pressedAttribute}"`,
});

const invertedError = new AssertionError({
actual: pressedAttribute,
expected: "false",
message: `Expected the element to NOT be pressed, but received aria-pressed="${pressedAttribute}"`,
});

return this.execute({
assertWhen: isPressed,
error,
invertedError,
});
}

/**
* Asserts that the element is a partially pressed button.
*
Comment thread
KeylaMunnoz marked this conversation as resolved.
* @returns the assertion instance.
*/

public toBePartiallyPressed(): this {
if (!isButtonElement(this.actual) || !isValidAriaPressed(this.actual)) {
throw new Error(
'.toBePartiallyPressed() requires a button, input[type="button"], or role="button" with valid aria-pressed',
Comment thread
KeylaMunnoz marked this conversation as resolved.
Outdated
);
}

const pressedAttribute = this.actual.getAttribute("aria-pressed");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe rename to pressedAttributeValue, since I get you could receive: true, false or mixed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain what does mixed specifically involve ? (for context and understanding)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Su sure ! mixed represents a partially pressed state so it's typically used when a toggle button controls multiple items and only some of them are in the pressed state.
In these cases, the button cannot be accurately described as either fully pressed (true) or not pressed (false), so mixed indicates that the controlled state is not consistent across the affected items.

const isPartiallyPressed = pressedAttribute === "mixed";

const error = new AssertionError({
actual: pressedAttribute,
expected: "mixed",
message: `Expected the element to be partially pressed, but received aria-pressed="${pressedAttribute}"`,
});

const invertedError = new AssertionError({
actual: pressedAttribute,
message: `Expected the element to NOT be partially pressed, but received aria-pressed="${pressedAttribute}"`,
});

return this.execute({
assertWhen: isPartiallyPressed,
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 @@ -28,3 +28,8 @@ export function getAccessibleDescription(actual: Element): string {

return normalizeText(combinedText);
}

export function isValidAriaPressed(element: Element): boolean {
const pressedAttribute = element.getAttribute("aria-pressed");
return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute);
}
15 changes: 15 additions & 0 deletions packages/dom/src/lib/helpers/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,18 @@ export function isElementEmpty(element: Element): boolean {
const nonCommentChildNodes = [...element.childNodes].filter(child => child.nodeType !== COMMENT_NODE_TYPE);
return nonCommentChildNodes.length === 0;
}

Comment thread
KeylaMunnoz marked this conversation as resolved.
export function isButtonElement(element: Element): boolean {
const roles = (element.getAttribute("role") || "")
.split(" ")
.map(role => role.trim());

const tagName = element.tagName.toLowerCase();
const type = element.getAttribute("type");

return (
Comment thread
KeylaMunnoz marked this conversation as resolved.
Outdated
tagName === "button"
|| (tagName === "input" && type === "button")
|| roles.includes("button")
);
}
237 changes: 237 additions & 0 deletions packages/dom/test/unit/lib/ElementAssertion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ElementAssertion } from "../../../src/lib/ElementAssertion";

import { HaveClassTest } from "./fixtures/HaveClassTest";
import { NestedElementsTest } from "./fixtures/NestedElementsTest";
import { PressedTestComponent } from "./fixtures/PressedTestComponent";
import { SimpleTest } from "./fixtures/SimpleTest";
import { WithAttributesTest } from "./fixtures/WithAttributesTest";
import { DescriptionTestComponent } from "./fixtures/descriptionTestComponent";
Expand Down Expand Up @@ -586,4 +587,240 @@ describe("[Unit] ElementAssertion.test.ts", () => {
});
});
});

describe(".toBePressed", () => {
context("when the element is a valid button-like element", () => {
context("when aria-pressed is \"true\"", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<PressedTestComponent />);
const button = getByTestId("button-pressed");
const test = new ElementAssertion(button);

expect(test.toBePressed()).toBeEqual(test);

expect(() => test.not.toBePressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to NOT be pressed, but received aria-pressed="true"');
});
});

context("when aria-pressed is \"false\"", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<PressedTestComponent />);
const button = getByTestId("button-not-pressed");
const test = new ElementAssertion(button);

expect(() => test.toBePressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to be pressed, but received aria-pressed="false"');

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

context("when aria-pressed is \"mixed\"", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<PressedTestComponent />);
const button = getByTestId("button-mixed");
const test = new ElementAssertion(button);

expect(() => test.toBePressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to be pressed, but received aria-pressed="mixed"');

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

context("when the element is an input with type=\"button\"", () => {
it("returns the assertion instance when aria-pressed is \"true\"", () => {
const { getByTestId } = render(<PressedTestComponent />);
const input = getByTestId("input-button-pressed");
const test = new ElementAssertion(input);

expect(test.toBePressed()).toBeEqual(test);

expect(() => test.not.toBePressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to NOT be pressed, but received aria-pressed="true"');
});

it("throws an assertion error when aria-pressed is \"false\"", () => {
const { getByTestId } = render(<PressedTestComponent />);
const input = getByTestId("input-button-not-pressed");
const test = new ElementAssertion(input);

expect(() => test.toBePressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to be pressed, but received aria-pressed="false"');

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

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

expect(test.toBePressed()).toBeEqual(test);

expect(() => test.not.toBePressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to NOT be pressed, but received aria-pressed="true"');
});

it("throws an assertion error when aria-pressed is \"false\"", () => {
const { getByTestId } = render(<PressedTestComponent />);
const div = getByTestId("role-button-not-pressed");
const test = new ElementAssertion(div);

expect(() => test.toBePressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to be pressed, but received aria-pressed="false"');

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

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

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

context("when aria-pressed is missing", () => {
it("throws a plain Error", () => {
const { getByTestId } = render(<PressedTestComponent />);
const button = getByTestId("button-no-aria-pressed");
const test = new ElementAssertion(button);

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

describe(".toBePartiallyPressed", () => {
context("when the element is a valid button-like element", () => {
context("when aria-pressed is \"mixed\"", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<PressedTestComponent />);
const button = getByTestId("button-mixed");
const test = new ElementAssertion(button);

expect(test.toBePartiallyPressed()).toBeEqual(test);

expect(() => test.not.toBePartiallyPressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to NOT be partially pressed, but received aria-pressed="mixed"');
});
});

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

expect(() => test.toBePartiallyPressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to be partially pressed, but received aria-pressed="true"');

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

context("when aria-pressed is \"false\"", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<PressedTestComponent />);
const button = getByTestId("button-not-pressed");
const test = new ElementAssertion(button);

expect(() => test.toBePartiallyPressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to be partially pressed, but received aria-pressed="false"');

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

context("when the element is an input with type=\"button\"", () => {
it("returns the assertion instance when aria-pressed is \"mixed\"", () => {
const { getByTestId } = render(<PressedTestComponent />);
const input = getByTestId("input-button-mixed");
const test = new ElementAssertion(input);

expect(test.toBePartiallyPressed()).toBeEqual(test);

expect(() => test.not.toBePartiallyPressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to NOT be partially pressed, but received aria-pressed="mixed"');
});

it("throws an assertion error when aria-pressed is \"false\"", () => {
const { getByTestId } = render(<PressedTestComponent />);
const input = getByTestId("input-button-not-pressed");
const test = new ElementAssertion(input);

expect(() => test.toBePartiallyPressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to be partially pressed, but received aria-pressed="false"');

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

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

expect(test.toBePartiallyPressed()).toBeEqual(test);

expect(() => test.not.toBePartiallyPressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to NOT be partially pressed, but received aria-pressed="mixed"');
});

it("throws an assertion error when aria-pressed is \"false\"", () => {
const { getByTestId } = render(<PressedTestComponent />);
const div = getByTestId("role-button-not-pressed");
const test = new ElementAssertion(div);

expect(() => test.toBePartiallyPressed())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to be partially pressed, but received aria-pressed="false"');

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

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

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

context("when aria-pressed is missing", () => {
it("throws a plain Error", () => {
const { getByTestId } = render(<PressedTestComponent />);
const button = getByTestId("button-no-aria-pressed");
const test = new ElementAssertion(button);

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

export function PressedTestComponent(): ReactElement {
return (
<div>
{/* <button> variants */}
<button data-testid="button-pressed" aria-pressed="true">{"Pressed"}</button>
<button data-testid="button-not-pressed" aria-pressed="false">{"Not pressed"}</button>
<button data-testid="button-mixed" aria-pressed="mixed">{"Mixed"}</button>
<button data-testid="button-no-aria-pressed">{"No aria-pressed"}</button>

{/* <input type="button"> variants */}
<input data-testid="input-button-pressed" type="button" aria-pressed="true" />
<input data-testid="input-button-not-pressed" type="button" aria-pressed="false" />
<input data-testid="input-button-mixed" type="button" aria-pressed="mixed" />

{/* role="button" variants */}
<div data-testid="role-button-pressed" role="button" aria-pressed="true">{"Pressed"}</div>
<div data-testid="role-button-not-pressed" role="button" aria-pressed="false">{"Not pressed"}</div>
<div data-testid="role-button-mixed" role="button" aria-pressed="mixed">{"Mixed"}</div>

{/* invalid element – no button role/tag */}
<div data-testid="non-button-element" aria-pressed="true">{"Not a button"}</div>
</div>
);
}
Loading