Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add withAriaSupport option #1194

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
60 changes: 44 additions & 16 deletions lib/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ export interface AssertionResult {
message: string;
}

export interface DOMAssertionOptions {
/**
* Whether to also consider `aria-*` attributes in semantic assertion helpers.
*
* {@link https://www.w3.org/TR/wai-aria-1.1/#state_prop_def}
*
* @see DOMAssertions#isChecked
* @see DOMAssertions#isNotChecked
* @see DOMAssertions#isRequired
* @see DOMAssertions#isNotRequired
* @see DOMAssertions#isDisabled
* @see DOMAssertions#isNotDisabled
* @see DOMAssertions#isEnabled
* @see DOMAssertions#isValid
* @see DOMAssertions#isNotValid
*/
withAriaSupport?: boolean;
}

export interface ExistsOptions {
count: number;
}
Expand All @@ -32,7 +51,8 @@ export default class DOMAssertions {
constructor(
private target: string | Element | null,
private rootElement: Element | Document,
private testContext: Assert
private testContext: Assert,
private options: DOMAssertionOptions = {}
) {}

/**
Expand Down Expand Up @@ -106,8 +126,9 @@ export default class DOMAssertions {
* assert.dom('input.active').isChecked();
*
* @see {@link #isNotChecked}
* {@link https://www.w3.org/TR/wai-aria-1.1/#aria-checked}
*/
isChecked(message?: string): DOMAssertions {
isChecked(message?: string, options = {}): DOMAssertions {
isChecked.call(this, message);
return this;
}
Expand All @@ -124,6 +145,7 @@ export default class DOMAssertions {
* assert.dom('input.active').isNotChecked();
*
* @see {@link #isChecked}
* {@link https://www.w3.org/TR/wai-aria-1.1/#aria-checked}
*/
isNotChecked(message?: string): DOMAssertions {
isNotChecked.call(this, message);
Expand Down Expand Up @@ -172,9 +194,10 @@ export default class DOMAssertions {
* assert.dom('input[type="text"]').isRequired();
*
* @see {@link #isNotRequired}
* {@link https://www.w3.org/TR/wai-aria-1.1/#aria-required}
*/
isRequired(message?: string): DOMAssertions {
isRequired.call(this, message);
isRequired(message?: string, options?: { withAriaSupport?: boolean }): DOMAssertions {
isRequired.call(this, message, options);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this is missing the fallback to this.options.withAriaSupport if the option is not explicitly specified in the passed in options argument

Copy link
Collaborator

Choose a reason for hiding this comment

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

hmm, looks like you've moved that fallback into the functions. I think I'd prefer it if the functions would always get an options object with a withAriaSupport value and don't have to depend on this except for this.pushResult(). 🤔

return this;
}

Expand All @@ -188,9 +211,10 @@ export default class DOMAssertions {
* assert.dom('input[type="text"]').isNotRequired();
*
* @see {@link #isRequired}
* {@link https://www.w3.org/TR/wai-aria-1.1/#aria-required}
*/
isNotRequired(message?: string): DOMAssertions {
isNotRequired.call(this, message);
isNotRequired(message?: string, options?: { withAriaSupport?: boolean }): DOMAssertions {
isNotRequired.call(this, message, options);
return this;
}

Expand All @@ -207,9 +231,10 @@ export default class DOMAssertions {
* assert.dom('.input').isValid();
*
* @see {@link #isValid}
* {@link https://www.w3.org/TR/wai-aria-1.1/#aria-invalid}
*/
isValid(message?: string): DOMAssertions {
isValid.call(this, message);
isValid(message?: string, options?: { withAriaSupport?: boolean }): DOMAssertions {
isValid.call(this, message, options);
return this;
}

Expand All @@ -226,9 +251,10 @@ export default class DOMAssertions {
* assert.dom('.input').isNotValid();
*
* @see {@link #isValid}
* {@link https://www.w3.org/TR/wai-aria-1.1/#aria-invalid}
*/
isNotValid(message?: string): DOMAssertions {
isValid.call(this, message, { inverted: true });
isNotValid(message?: string, options?: { withAriaSupport?: boolean }): DOMAssertions {
isValid.call(this, message, { ...options, inverted: true });
return this;
}

Expand Down Expand Up @@ -577,9 +603,10 @@ export default class DOMAssertions {
* assert.dom('.foo').isDisabled();
*
* @see {@link #isNotDisabled}
* {@link https://www.w3.org/TR/wai-aria-1.1/#aria-disabled}
*/
isDisabled(message?: string): DOMAssertions {
isDisabled.call(this, message);
isDisabled(message?: string, options?: { withAriaSupport?: boolean }): DOMAssertions {
isDisabled.call(this, message, options);
return this;
}

Expand All @@ -595,14 +622,15 @@ export default class DOMAssertions {
* assert.dom('.foo').isNotDisabled();
*
* @see {@link #isDisabled}
* {@link https://www.w3.org/TR/wai-aria-1.1/#aria-disabled}
*/
isNotDisabled(message?: string): DOMAssertions {
isDisabled.call(this, message, { inverted: true });
isNotDisabled(message?: string, options?: { withAriaSupport?: boolean }): DOMAssertions {
isDisabled.call(this, message, { ...options, inverted: true });
return this;
}

isEnabled(message?: string): DOMAssertions {
return this.isNotDisabled(message);
isEnabled(message?: string, options?: { withAriaSupport?: boolean }): DOMAssertions {
return this.isNotDisabled(message, options);
}

/**
Expand Down
9 changes: 7 additions & 2 deletions lib/assertions/is-checked.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import elementToString from '../helpers/element-to-string';

export default function checked(message?: string) {
export default function checked(message?: string, options?: { withAriaSupport?: boolean }) {
let {
// @TODO: Remove inline default in favor of consistent global default.
withAriaSupport = this.options.withAriaSupport ?? true,
} = options;

let element = this.findTargetElement();
if (!element) return;

Expand All @@ -10,7 +15,7 @@ export default function checked(message?: string) {
let result = isChecked;

let hasCheckedProp = isChecked || isNotChecked;
if (!hasCheckedProp) {
if (withAriaSupport && !hasCheckedProp) {
let ariaChecked = element.getAttribute('aria-checked');
if (ariaChecked !== null) {
result = ariaChecked === 'true';
Expand Down
19 changes: 17 additions & 2 deletions lib/assertions/is-disabled.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
export default function isDisabled(message?: string, options: { inverted?: boolean } = {}) {
let { inverted } = options;
export default function isDisabled(
message?: string,
options?: { inverted?: boolean; withAriaSupport?: boolean }
) {
let {
inverted,

// @TODO: Remove inline default in favor of consistent global default.
withAriaSupport = this.options.withAriaSupport ?? false,
} = options;

let element = this.findTargetElement();
if (!element) return;
Expand All @@ -20,6 +28,13 @@ export default function isDisabled(message?: string, options: { inverted?: boole

let result = element.disabled === !inverted;

if (withAriaSupport && !result) {
let ariaDisabled = element.getAttribute('aria-disabled');
if (ariaDisabled !== null) {
result = ariaDisabled === 'true';
}
}

let actual =
element.disabled === false
? `Element ${this.targetDescription} is not disabled`
Expand Down
9 changes: 7 additions & 2 deletions lib/assertions/is-not-checked.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import elementToString from '../helpers/element-to-string';

export default function notChecked(message?: string) {
export default function notChecked(message?: string, options?: { withAriaSupport?: boolean }) {
let {
// @TODO: Remove inline default in favor of consistent global default.
withAriaSupport = this.options.withAriaSupport ?? true,
} = options;

let element = this.findTargetElement();
if (!element) return;

Expand All @@ -10,7 +15,7 @@ export default function notChecked(message?: string) {
let result = !isChecked;

let hasCheckedProp = isChecked || isNotChecked;
if (!hasCheckedProp) {
if (withAriaSupport && !hasCheckedProp) {
let ariaChecked = element.getAttribute('aria-checked');
if (ariaChecked !== null) {
result = ariaChecked !== 'true';
Expand Down
15 changes: 14 additions & 1 deletion lib/assertions/is-not-required.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import elementToString from '../helpers/element-to-string';

export default function notRequired(message?: string) {
export default function notRequired(message?: string, options?: { withAriaSupport?: boolean }) {
let {
// @TODO: Remove inline default in favor of consistent global default.
withAriaSupport = this.options.withAriaSupport ?? false,
} = options;

let element = this.findTargetElement();
if (!element) return;

Expand All @@ -15,6 +20,14 @@ export default function notRequired(message?: string) {
}

let result = element.required === false;

if (withAriaSupport && result) {
let ariaRequired = element.getAttribute('aria-required');
if (ariaRequired !== null) {
result = ariaRequired === 'false';
}
}

let actual = !result ? 'required' : 'not required';
let expected = 'not required';

Expand Down
15 changes: 14 additions & 1 deletion lib/assertions/is-required.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import elementToString from '../helpers/element-to-string';

export default function required(message?: string) {
export default function required(message?: string, options?: { withAriaSupport?: boolean }) {
let {
// @TODO: Remove inline default in favor of consistent global default.
withAriaSupport = this.options.withAriaSupport ?? false,
} = options;

let element = this.findTargetElement();
if (!element) return;

Expand All @@ -15,6 +20,14 @@ export default function required(message?: string) {
}

let result = element.required === true;

if (withAriaSupport && !result) {
let ariaRequired = element.getAttribute('aria-required');
if (ariaRequired !== null) {
result = ariaRequired === 'true';
}
}

let actual = result ? 'required' : 'not required';
let expected = 'required';

Expand Down
29 changes: 26 additions & 3 deletions lib/assertions/is-valid.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import elementToString from '../helpers/element-to-string';

export default function isValid(message?: string, options: { inverted?: boolean } = {}) {
export default function isValid(
message?: string,
options: { inverted?: boolean; withAriaSupport?: boolean } = {}
) {
let {
inverted,

// @TODO: Remove inline default in favor of consistent global default.
withAriaSupport = this.options.withAriaSupport ?? false,
} = options;

let element = this.findTargetElement();
if (!element) return;

Expand All @@ -18,9 +28,22 @@ export default function isValid(message?: string, options: { inverted?: boolean
}

let validity = element.reportValidity() === true;
let result = validity === !options.inverted;
let result = validity === !inverted;

// https://www.w3.org/TR/wai-aria-1.1/#aria-invalid
if (withAriaSupport && result) {
let ariaInvalid = element.getAttribute('aria-invalid');
if (ariaInvalid !== null) {
if (inverted) {
result = ['grammar', 'spelling', 'true'].includes(ariaInvalid);
} else {
result = ariaInvalid === 'false';
}
}
}

let actual = validity ? 'valid' : 'not valid';
let expected = options.inverted ? 'not valid' : 'valid';
let expected = inverted ? 'not valid' : 'valid';

if (!message) {
message = `Element ${elementToString(this.target)} is ${actual}`;
Expand Down
6 changes: 3 additions & 3 deletions lib/helpers/test-assertions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import DOMAssertions, { AssertionResult } from '../assertions';
import DOMAssertions, { AssertionResult, DOMAssertionOptions } from '../assertions';

export default class TestAssertions {
public results: AssertionResult[] = [];

dom(target: string | Element | null, rootElement?: Element) {
return new DOMAssertions(target, rootElement || document, this as any);
dom(target: string | Element | null, rootElement?: Element, options?: DOMAssertionOptions) {
return new DOMAssertions(target, rootElement || document, this as any, options);
}

pushResult(result: AssertionResult) {
Expand Down
14 changes: 10 additions & 4 deletions lib/install.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import DOMAssertions from './assertions';
import DOMAssertions, { DOMAssertionOptions } from './assertions';
import { getRootElement } from './root-element';

export default function (assert: Assert) {
assert.dom = function (target?: string | Element | null, rootElement?: Element): DOMAssertions {
export interface InstallOptions extends DOMAssertionOptions {}

export default function (assert: Assert, options: InstallOptions = {}) {
assert.dom = function (
target?: string | Element | null,
rootElement?: Element,
options: DOMAssertionOptions = {}
): DOMAssertions {
if (!isValidRootElement(rootElement)) {
throw new Error(`${rootElement} is not a valid root element`);
}
Expand All @@ -13,7 +19,7 @@ export default function (assert: Assert) {
target = rootElement instanceof Element ? rootElement : null;
}

return new DOMAssertions(target, rootElement, this);
return new DOMAssertions(target, rootElement, this, options);
};

function isValidRootElement(element: any): element is Element {
Expand Down
6 changes: 3 additions & 3 deletions lib/qunit-dom-modules.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import install from './install';
import install, { InstallOptions } from './install';
import { overrideRootElement } from './root-element';

export { default as install } from './install';

interface SetupOptions {
interface SetupOptions extends InstallOptions {
getRootElement?: () => Element | null;
}

export function setup(assert: Assert, options: SetupOptions = {}) {
install(assert);
install(assert, options);

const getRootElement =
typeof options.getRootElement === 'function'
Expand Down
7 changes: 6 additions & 1 deletion lib/qunit-dom.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
/* global QUnit */

import type DOMAssertions from './assertions';
import type { DOMAssertionOptions } from './assertions';
import install from './install';

export { setup } from './qunit-dom-modules';

declare global {
interface Assert {
dom(target?: string | Element | null, rootElement?: Element): DOMAssertions;
dom(
target?: string | Element | null,
rootElement?: Element,
options?: DOMAssertionOptions
): DOMAssertions;
}
}

Expand Down