diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts index 8207cc9..ca3ce5d 100644 --- a/packages/dom/src/lib/ElementAssertion.ts +++ b/packages/dom/src/lib/ElementAssertion.ts @@ -2,7 +2,7 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; import equal from "fast-deep-equal"; import { getAccessibleDescription, isValidAriaPressed } from "./helpers/accessibility"; -import { isButtonElement, isElementEmpty, normalizeHtml } from "./helpers/dom"; +import { isButtonElement, isElementEmpty, isElementOrAncestorDisabled, normalizeHtml } from "./helpers/dom"; import { getExpectedAndReceivedStyles } from "./helpers/styles"; export class ElementAssertion extends Assertion { @@ -479,6 +479,58 @@ export class ElementAssertion extends Assertion { }); } + /** + * Asserts that the element is disabled. + * + * @example + * expect(element).toBeDisabled(); + * expect(element).not.toBeDisabled(); + * + * @returns the assertion instance. + */ + public toBeDisabled(): this { + const isDisabled = isElementOrAncestorDisabled(this.actual); + const error = new AssertionError({ + actual: this.actual, + message: "Expected the element to be disabled", + }); + const invertedError = new AssertionError({ + actual: this.actual, + message: "Expected the element to NOT be disabled", + }); + return this.execute({ + assertWhen: isDisabled, + error, + invertedError, + }); + } + + /** + * Asserts that the element is enabled. + * + * @example + * expect(element).toBeEnabled(); + * expect(element).not.toBeEnabled(); + * + * @returns the assertion instance. + */ + public toBeEnabled(): this { + const isEnabled = !isElementOrAncestorDisabled(this.actual); + const error = new AssertionError({ + actual: this.actual, + message: "Expected the element to be enabled", + }); + const invertedError = new AssertionError({ + actual: this.actual, + message: "Expected the element to NOT be enabled", + }); + return this.execute({ + assertWhen: isEnabled, + error, + invertedError, + }); + } + /** * Helper method to assert the presence or absence of class names. * diff --git a/packages/dom/src/lib/helpers/dom.ts b/packages/dom/src/lib/helpers/dom.ts index 7850bc8..e32cb14 100644 --- a/packages/dom/src/lib/helpers/dom.ts +++ b/packages/dom/src/lib/helpers/dom.ts @@ -25,3 +25,56 @@ export function isButtonElement(element: Element): boolean { return isNativeButton || hasButtonRole; } + +const FORM_TAGS = [ + "fieldset", + "input", + "select", + "optgroup", + "option", + "button", + "textarea", +]; + +function isFirstLegendChildOfFieldset(element: Element, parent: Element | null): boolean { + return ( + element.tagName.toLowerCase() === "legend" + && parent?.tagName.toLowerCase() === "fieldset" + && element === Array.from(parent.children).find(child => child.tagName.toLowerCase() === "legend") + ); +} + +function isElementDisabledByParent(element: Element, parent: Element | null): boolean { + return ( + parent !== null + && isElementDisabled(parent) && !isFirstLegendChildOfFieldset(element, parent) + ); +} + +function isCustomElement(tag: string): boolean { + return tag.includes("-"); +} + +function canElementBeDisabled(element: Element): boolean { + const tag = element.tagName.toLowerCase(); + return FORM_TAGS.includes(tag) || isCustomElement(tag); +} + +function isElementDisabled(element: Element): boolean { + return canElementBeDisabled(element) && element.hasAttribute("disabled"); +} + +function isAncestorDisabled(element: Element): boolean { + const parent = element.parentElement; + return ( + parent !== null + && (isElementDisabledByParent(element, parent) || isAncestorDisabled(parent)) + ); +} + +export function isElementOrAncestorDisabled(element: Element): boolean { + return ( + canElementBeDisabled(element) + && (isElementDisabled(element) || isAncestorDisabled(element)) + ); +} diff --git a/packages/dom/test/unit/lib/ElementAssertion.test.tsx b/packages/dom/test/unit/lib/ElementAssertion.test.tsx index 4b101a0..154c6c1 100644 --- a/packages/dom/test/unit/lib/ElementAssertion.test.tsx +++ b/packages/dom/test/unit/lib/ElementAssertion.test.tsx @@ -3,6 +3,7 @@ import { render } from "@testing-library/react"; import { ElementAssertion } from "../../../src/lib/ElementAssertion"; +import { DisabledTestComponent } from "./fixtures/DisabledTestComponent"; import { HaveClassTest } from "./fixtures/HaveClassTest"; import { NestedElementsTest } from "./fixtures/NestedElementsTest"; import { PressedTestComponent } from "./fixtures/PressedTestComponent"; @@ -904,4 +905,87 @@ describe("[Unit] ElementAssertion.test.ts", () => { }); }); }); + + describe(".toBeDisabled", () => { + context("when the element is disabled", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render(); + + const buttonDisabled = getByTestId("button-disabled"); + expect(new ElementAssertion(buttonDisabled).toBeDisabled()).toBeInstanceOf(ElementAssertion); + + const inputDisabled = getByTestId("input-disabled"); + expect(new ElementAssertion(inputDisabled).toBeDisabled()).toBeInstanceOf(ElementAssertion); + + const customDisabled = getByTestId("custom-disabled"); + expect(new ElementAssertion(customDisabled).toBeDisabled()).toBeInstanceOf(ElementAssertion); + + const fieldsetChildDisabled = getByTestId("fieldset-child-disabled"); + expect(new ElementAssertion(fieldsetChildDisabled).toBeDisabled()).toBeInstanceOf(ElementAssertion); + + const fieldsetLegendSecondChildDisabled = getByTestId("fieldset-legend-second-child-disabled"); + expect(new ElementAssertion(fieldsetLegendSecondChildDisabled).toBeDisabled()).toBeInstanceOf(ElementAssertion); + + const fieldsetNestedChildDisabled = getByTestId("fieldset-nested-child-disabled"); + expect(new ElementAssertion(fieldsetNestedChildDisabled).toBeDisabled()).toBeInstanceOf(ElementAssertion); + }); + }); + + context("when the element is not disabled", () => { + it("throws an assertion error", () => { + const { getByTestId } = render(); + + const buttonEnabled = getByTestId("button-enabled"); + const test = new ElementAssertion(buttonEnabled); + + expect(() => test.toBeDisabled()) + .toThrowError(AssertionError) + .toHaveMessage("Expected the element to be disabled"); + expect(test.not.toBeDisabled()).toBeEqual(test); + + const divDisabled = getByTestId("div-disabled"); + const testDiv = new ElementAssertion(divDisabled); + expect(() => testDiv.toBeDisabled()) + .toThrowError(AssertionError) + .toHaveMessage("Expected the element to be disabled"); + + const fieldsetLegendFirstChild = getByTestId("fieldset-legend-first-child"); + const testLegendFirstChild = new ElementAssertion(fieldsetLegendFirstChild); + expect(() => testLegendFirstChild.toBeDisabled()) + .toThrowError(AssertionError) + .toHaveMessage("Expected the element to be disabled"); + }); + }); + }); + + describe(".toBeEnabled", () => { + context("when the element is enabled", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render(); + + const buttonEnabled = getByTestId("button-enabled"); + expect(new ElementAssertion(buttonEnabled).toBeEnabled()).toBeInstanceOf(ElementAssertion); + + const divDisabled = getByTestId("div-disabled"); + expect(new ElementAssertion(divDisabled).toBeEnabled()).toBeInstanceOf(ElementAssertion); + + const fieldsetLegendFirstChild = getByTestId("fieldset-legend-first-child"); + expect(new ElementAssertion(fieldsetLegendFirstChild).toBeEnabled()).toBeInstanceOf(ElementAssertion); + }); + }); + + context("when the element is not enabled", () => { + it("throws an assertion error", () => { + const { getByTestId } = render(); + + const buttonDisabled = getByTestId("button-disabled"); + const test = new ElementAssertion(buttonDisabled); + + expect(() => test.toBeEnabled()) + .toThrowError(AssertionError) + .toHaveMessage("Expected the element to be enabled"); + expect(test.not.toBeEnabled()).toBeEqual(test); + }); + }); + }); }); diff --git a/packages/dom/test/unit/lib/fixtures/DisabledTestComponent.tsx b/packages/dom/test/unit/lib/fixtures/DisabledTestComponent.tsx new file mode 100644 index 0000000..3da8c53 --- /dev/null +++ b/packages/dom/test/unit/lib/fixtures/DisabledTestComponent.tsx @@ -0,0 +1,29 @@ +import type { ReactElement } from "react"; + +export function DisabledTestComponent(): ReactElement { + return ( +
+ + + + +
{"Div Disabled"}
+ + {/* @ts-expect-error - Custom element */} + + +
+ + + + + + + +
+ +
+
+
+ ); +}