Skip to content

Commit 29f9e41

Browse files
authored
feat(dom): toBePressed and toBePartiallyPressed (#172)
1 parent b1d45c9 commit 29f9e41

5 files changed

Lines changed: 363 additions & 2 deletions

File tree

packages/dom/src/lib/ElementAssertion.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Assertion, AssertionError } from "@assertive-ts/core";
22
import equal from "fast-deep-equal";
33

4-
import { getAccessibleDescription } from "./helpers/accessibility";
5-
import { isElementEmpty } from "./helpers/dom";
4+
import { getAccessibleDescription, isValidAriaPressed } from "./helpers/accessibility";
5+
import { isButtonElement, isElementEmpty } from "./helpers/dom";
66
import { getExpectedAndReceivedStyles } from "./helpers/styles";
77

88
export class ElementAssertion<T extends Element> extends Assertion<T> {
@@ -355,6 +355,85 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
355355
});
356356
}
357357

358+
/**
359+
* Asserts that the element is a pressed button.
360+
*
361+
* @example
362+
* // It takes into account aria-pressed attribute
363+
* expect(element).toBePressed();
364+
* expect(element).not.toBePressed();
365+
*
366+
* @returns the assertion instance.
367+
*/
368+
369+
public toBePressed(): this {
370+
if (!isButtonElement(this.actual) || !isValidAriaPressed(this.actual)) {
371+
throw new Error(
372+
'Expected a button or button-like control with a valid pressed state: "true", "false", or "mixed".',
373+
);
374+
}
375+
376+
const pressedAttributeValue = this.actual.getAttribute("aria-pressed");
377+
const isPressed = pressedAttributeValue === "true";
378+
379+
const error = new AssertionError({
380+
actual: pressedAttributeValue,
381+
expected: "true",
382+
message: `Expected the element to be pressed, but received aria-pressed="${pressedAttributeValue}"`,
383+
});
384+
385+
const invertedError = new AssertionError({
386+
actual: pressedAttributeValue,
387+
expected: "false",
388+
message: `Expected the element to NOT be pressed, but received aria-pressed="${pressedAttributeValue}"`,
389+
});
390+
391+
return this.execute({
392+
assertWhen: isPressed,
393+
error,
394+
invertedError,
395+
});
396+
}
397+
398+
/**
399+
* Asserts that the element is a partially pressed button.
400+
*
401+
* @example
402+
* // It takes into account aria-pressed attribute
403+
* expect(element).toBePartiallyPressed();
404+
* expect(element).not.toBePartiallyPressed();
405+
*
406+
* @returns the assertion instance.
407+
*/
408+
409+
public toBePartiallyPressed(): this {
410+
if (!isButtonElement(this.actual) || !isValidAriaPressed(this.actual)) {
411+
throw new Error(
412+
'Expected a button or button-like control with a valid pressed state: "true", "false", or "mixed".',
413+
);
414+
}
415+
416+
const pressedAttributeValue = this.actual.getAttribute("aria-pressed");
417+
const isPartiallyPressed = pressedAttributeValue === "mixed";
418+
419+
const error = new AssertionError({
420+
actual: pressedAttributeValue,
421+
expected: "mixed",
422+
message: `Expected the element to be partially pressed, but received aria-pressed="${pressedAttributeValue}"`,
423+
});
424+
425+
const invertedError = new AssertionError({
426+
actual: pressedAttributeValue,
427+
message: `Expected the element to NOT be partially pressed, but received aria-pressed="${pressedAttributeValue}"`,
428+
});
429+
430+
return this.execute({
431+
assertWhen: isPartiallyPressed,
432+
error,
433+
invertedError,
434+
});
435+
}
436+
358437
/**
359438
* Helper method to assert the presence or absence of class names.
360439
*

packages/dom/src/lib/helpers/accessibility.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,8 @@ export function getAccessibleDescription(actual: Element): string {
2828

2929
return normalizeText(combinedText);
3030
}
31+
32+
export function isValidAriaPressed(element: Element): boolean {
33+
const pressedAttribute = element.getAttribute("aria-pressed");
34+
return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute);
35+
}

packages/dom/src/lib/helpers/dom.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,17 @@ export function isElementEmpty(element: Element): boolean {
44
const nonCommentChildNodes = [...element.childNodes].filter(child => child.nodeType !== COMMENT_NODE_TYPE);
55
return nonCommentChildNodes.length === 0;
66
}
7+
8+
export function isButtonElement(element: Element): boolean {
9+
const roles = (element.getAttribute("role") || "")
10+
.split(" ")
11+
.map(role => role.trim());
12+
13+
const tagName = element.tagName.toLowerCase();
14+
const type = element.getAttribute("type");
15+
16+
const isNativeButton = tagName === "button" || (tagName === "input" && type === "button");
17+
const hasButtonRole = roles.includes("button");
18+
19+
return isNativeButton || hasButtonRole;
20+
}

packages/dom/test/unit/lib/ElementAssertion.test.tsx

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ElementAssertion } from "../../../src/lib/ElementAssertion";
55

66
import { HaveClassTest } from "./fixtures/HaveClassTest";
77
import { NestedElementsTest } from "./fixtures/NestedElementsTest";
8+
import { PressedTestComponent } from "./fixtures/PressedTestComponent";
89
import { SimpleTest } from "./fixtures/SimpleTest";
910
import { WithAttributesTest } from "./fixtures/WithAttributesTest";
1011
import { DescriptionTestComponent } from "./fixtures/descriptionTestComponent";
@@ -586,4 +587,240 @@ describe("[Unit] ElementAssertion.test.ts", () => {
586587
});
587588
});
588589
});
590+
591+
describe(".toBePressed", () => {
592+
context("when the element is a valid button-like element", () => {
593+
context("when aria-pressed is \"true\"", () => {
594+
it("returns the assertion instance", () => {
595+
const { getByTestId } = render(<PressedTestComponent />);
596+
const button = getByTestId("button-pressed");
597+
const test = new ElementAssertion(button);
598+
599+
expect(test.toBePressed()).toBeEqual(test);
600+
601+
expect(() => test.not.toBePressed())
602+
.toThrowError(AssertionError)
603+
.toHaveMessage('Expected the element to NOT be pressed, but received aria-pressed="true"');
604+
});
605+
});
606+
607+
context("when aria-pressed is \"false\"", () => {
608+
it("throws an assertion error", () => {
609+
const { getByTestId } = render(<PressedTestComponent />);
610+
const button = getByTestId("button-not-pressed");
611+
const test = new ElementAssertion(button);
612+
613+
expect(() => test.toBePressed())
614+
.toThrowError(AssertionError)
615+
.toHaveMessage('Expected the element to be pressed, but received aria-pressed="false"');
616+
617+
expect(test.not.toBePressed()).toBeEqual(test);
618+
});
619+
});
620+
621+
context("when aria-pressed is \"mixed\"", () => {
622+
it("throws an assertion error", () => {
623+
const { getByTestId } = render(<PressedTestComponent />);
624+
const button = getByTestId("button-mixed");
625+
const test = new ElementAssertion(button);
626+
627+
expect(() => test.toBePressed())
628+
.toThrowError(AssertionError)
629+
.toHaveMessage('Expected the element to be pressed, but received aria-pressed="mixed"');
630+
631+
expect(test.not.toBePressed()).toBeEqual(test);
632+
});
633+
});
634+
635+
context("when the element is an input with type=\"button\"", () => {
636+
it("returns the assertion instance when aria-pressed is \"true\"", () => {
637+
const { getByTestId } = render(<PressedTestComponent />);
638+
const input = getByTestId("input-button-pressed");
639+
const test = new ElementAssertion(input);
640+
641+
expect(test.toBePressed()).toBeEqual(test);
642+
643+
expect(() => test.not.toBePressed())
644+
.toThrowError(AssertionError)
645+
.toHaveMessage('Expected the element to NOT be pressed, but received aria-pressed="true"');
646+
});
647+
648+
it("throws an assertion error when aria-pressed is \"false\"", () => {
649+
const { getByTestId } = render(<PressedTestComponent />);
650+
const input = getByTestId("input-button-not-pressed");
651+
const test = new ElementAssertion(input);
652+
653+
expect(() => test.toBePressed())
654+
.toThrowError(AssertionError)
655+
.toHaveMessage('Expected the element to be pressed, but received aria-pressed="false"');
656+
657+
expect(test.not.toBePressed()).toBeEqual(test);
658+
});
659+
});
660+
661+
context("when the element has role=\"button\"", () => {
662+
it("returns the assertion instance when aria-pressed is \"true\"", () => {
663+
const { getByTestId } = render(<PressedTestComponent />);
664+
const div = getByTestId("role-button-pressed");
665+
const test = new ElementAssertion(div);
666+
667+
expect(test.toBePressed()).toBeEqual(test);
668+
669+
expect(() => test.not.toBePressed())
670+
.toThrowError(AssertionError)
671+
.toHaveMessage('Expected the element to NOT be pressed, but received aria-pressed="true"');
672+
});
673+
674+
it("throws an assertion error when aria-pressed is \"false\"", () => {
675+
const { getByTestId } = render(<PressedTestComponent />);
676+
const div = getByTestId("role-button-not-pressed");
677+
const test = new ElementAssertion(div);
678+
679+
expect(() => test.toBePressed())
680+
.toThrowError(AssertionError)
681+
.toHaveMessage('Expected the element to be pressed, but received aria-pressed="false"');
682+
683+
expect(test.not.toBePressed()).toBeEqual(test);
684+
});
685+
});
686+
});
687+
688+
context("when the element is not a valid button-like element", () => {
689+
it("throws a plain Error", () => {
690+
const { getByTestId } = render(<PressedTestComponent />);
691+
const div = getByTestId("non-button-element");
692+
const test = new ElementAssertion(div);
693+
694+
expect(() => test.toBePressed()).toThrowError(Error);
695+
});
696+
});
697+
698+
context("when aria-pressed is missing", () => {
699+
it("throws a plain Error", () => {
700+
const { getByTestId } = render(<PressedTestComponent />);
701+
const button = getByTestId("button-no-aria-pressed");
702+
const test = new ElementAssertion(button);
703+
704+
expect(() => test.toBePressed()).toThrowError(Error);
705+
});
706+
});
707+
});
708+
709+
describe(".toBePartiallyPressed", () => {
710+
context("when the element is a valid button-like element", () => {
711+
context("when aria-pressed is \"mixed\"", () => {
712+
it("returns the assertion instance", () => {
713+
const { getByTestId } = render(<PressedTestComponent />);
714+
const button = getByTestId("button-mixed");
715+
const test = new ElementAssertion(button);
716+
717+
expect(test.toBePartiallyPressed()).toBeEqual(test);
718+
719+
expect(() => test.not.toBePartiallyPressed())
720+
.toThrowError(AssertionError)
721+
.toHaveMessage('Expected the element to NOT be partially pressed, but received aria-pressed="mixed"');
722+
});
723+
});
724+
725+
context("when aria-pressed is \"true\"", () => {
726+
it("throws an assertion error", () => {
727+
const { getByTestId } = render(<PressedTestComponent />);
728+
const button = getByTestId("button-pressed");
729+
const test = new ElementAssertion(button);
730+
731+
expect(() => test.toBePartiallyPressed())
732+
.toThrowError(AssertionError)
733+
.toHaveMessage('Expected the element to be partially pressed, but received aria-pressed="true"');
734+
735+
expect(test.not.toBePartiallyPressed()).toBeEqual(test);
736+
});
737+
});
738+
739+
context("when aria-pressed is \"false\"", () => {
740+
it("throws an assertion error", () => {
741+
const { getByTestId } = render(<PressedTestComponent />);
742+
const button = getByTestId("button-not-pressed");
743+
const test = new ElementAssertion(button);
744+
745+
expect(() => test.toBePartiallyPressed())
746+
.toThrowError(AssertionError)
747+
.toHaveMessage('Expected the element to be partially pressed, but received aria-pressed="false"');
748+
749+
expect(test.not.toBePartiallyPressed()).toBeEqual(test);
750+
});
751+
});
752+
753+
context("when the element is an input with type=\"button\"", () => {
754+
it("returns the assertion instance when aria-pressed is \"mixed\"", () => {
755+
const { getByTestId } = render(<PressedTestComponent />);
756+
const input = getByTestId("input-button-mixed");
757+
const test = new ElementAssertion(input);
758+
759+
expect(test.toBePartiallyPressed()).toBeEqual(test);
760+
761+
expect(() => test.not.toBePartiallyPressed())
762+
.toThrowError(AssertionError)
763+
.toHaveMessage('Expected the element to NOT be partially pressed, but received aria-pressed="mixed"');
764+
});
765+
766+
it("throws an assertion error when aria-pressed is \"false\"", () => {
767+
const { getByTestId } = render(<PressedTestComponent />);
768+
const input = getByTestId("input-button-not-pressed");
769+
const test = new ElementAssertion(input);
770+
771+
expect(() => test.toBePartiallyPressed())
772+
.toThrowError(AssertionError)
773+
.toHaveMessage('Expected the element to be partially pressed, but received aria-pressed="false"');
774+
775+
expect(test.not.toBePartiallyPressed()).toBeEqual(test);
776+
});
777+
});
778+
779+
context("when the element has role=\"button\"", () => {
780+
it("returns the assertion instance when aria-pressed is \"mixed\"", () => {
781+
const { getByTestId } = render(<PressedTestComponent />);
782+
const div = getByTestId("role-button-mixed");
783+
const test = new ElementAssertion(div);
784+
785+
expect(test.toBePartiallyPressed()).toBeEqual(test);
786+
787+
expect(() => test.not.toBePartiallyPressed())
788+
.toThrowError(AssertionError)
789+
.toHaveMessage('Expected the element to NOT be partially pressed, but received aria-pressed="mixed"');
790+
});
791+
792+
it("throws an assertion error when aria-pressed is \"false\"", () => {
793+
const { getByTestId } = render(<PressedTestComponent />);
794+
const div = getByTestId("role-button-not-pressed");
795+
const test = new ElementAssertion(div);
796+
797+
expect(() => test.toBePartiallyPressed())
798+
.toThrowError(AssertionError)
799+
.toHaveMessage('Expected the element to be partially pressed, but received aria-pressed="false"');
800+
801+
expect(test.not.toBePartiallyPressed()).toBeEqual(test);
802+
});
803+
});
804+
});
805+
806+
context("when the element is not a valid button-like element", () => {
807+
it("throws a plain Error", () => {
808+
const { getByTestId } = render(<PressedTestComponent />);
809+
const div = getByTestId("non-button-element");
810+
const test = new ElementAssertion(div);
811+
812+
expect(() => test.toBePartiallyPressed()).toThrowError(Error);
813+
});
814+
});
815+
816+
context("when aria-pressed is missing", () => {
817+
it("throws a plain Error", () => {
818+
const { getByTestId } = render(<PressedTestComponent />);
819+
const button = getByTestId("button-no-aria-pressed");
820+
const test = new ElementAssertion(button);
821+
822+
expect(() => test.toBePartiallyPressed()).toThrowError(Error);
823+
});
824+
});
825+
});
589826
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { ReactElement } from "react";
2+
3+
export function PressedTestComponent(): ReactElement {
4+
return (
5+
<div>
6+
{/* <button> variants */}
7+
<button data-testid="button-pressed" aria-pressed="true">{"Pressed"}</button>
8+
<button data-testid="button-not-pressed" aria-pressed="false">{"Not pressed"}</button>
9+
<button data-testid="button-mixed" aria-pressed="mixed">{"Mixed"}</button>
10+
<button data-testid="button-no-aria-pressed">{"No aria-pressed"}</button>
11+
12+
{/* <input type="button"> variants */}
13+
<input data-testid="input-button-pressed" type="button" aria-pressed="true" />
14+
<input data-testid="input-button-not-pressed" type="button" aria-pressed="false" />
15+
<input data-testid="input-button-mixed" type="button" aria-pressed="mixed" />
16+
17+
{/* role="button" variants */}
18+
<div data-testid="role-button-pressed" role="button" aria-pressed="true">{"Pressed"}</div>
19+
<div data-testid="role-button-not-pressed" role="button" aria-pressed="false">{"Not pressed"}</div>
20+
<div data-testid="role-button-mixed" role="button" aria-pressed="mixed">{"Mixed"}</div>
21+
22+
{/* invalid element – no button role/tag */}
23+
<div data-testid="non-button-element" aria-pressed="true">{"Not a button"}</div>
24+
</div>
25+
);
26+
}

0 commit comments

Comments
 (0)