Skip to content

Commit

Permalink
Merge pull request #395 from YusukeHirao/features/accname
Browse files Browse the repository at this point in the history
Add getting accessible name API to the element
  • Loading branch information
YusukeHirao committed Mar 6, 2022
2 parents 4c735cc + 0eb3a1e commit dfc689e
Show file tree
Hide file tree
Showing 30 changed files with 485 additions and 50 deletions.
1 change: 1 addition & 0 deletions packages/@markuplint/ml-core/markuplint-recommended.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"no-refer-to-non-existent-id": true,
"no-use-event-handler-attr": false,
"permitted-contents": true,
"require-accessible-name": true,
"required-attr": true,
"required-element": false,
"required-h1": true,
Expand Down
1 change: 1 addition & 0 deletions packages/@markuplint/ml-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@markuplint/ml-spec": "2.0.0",
"@markuplint/parser-utils": "2.0.0",
"debug": "^4.3.3",
"dom-accessibility-api": "^0.5.13",
"postcss-selector-parser": "^6.0.9",
"tslib": "^2.3.1"
},
Expand Down
27 changes: 27 additions & 0 deletions packages/@markuplint/ml-core/src/ml-dom/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ export default class MLDOMDocument<T extends RuleConfigValue, O = null> {
*/
readonly nodeStore = new NodeStore();

/**
* Window object for calling the `getComputedStyle` and the `getPropertyValue` that are needed by **Accessible Name and Description Computation**.
* But it always returns the empty object.
* (It may improve to possible to compute the name from the `style` attribute in the future.)
*/
readonly defaultView = {
getComputedStyle(_el: MLDOMElement<any, any>) {
return {
getPropertyValue(_propName: string) {
return {};
},
};
},
};

#filename?: string;

/**
Expand Down Expand Up @@ -189,6 +204,18 @@ export default class MLDOMDocument<T extends RuleConfigValue, O = null> {
);
}

querySelectorAll(query: string) {
return this.matchNodes(query);
}

querySelector(query: string) {
return this.querySelectorAll(query)[0] || null;
}

getElementById(id: string) {
return this.querySelector(`#${id}`);
}

toString() {
const html: string[] = [];
for (const node of this.nodeList) {
Expand Down
85 changes: 85 additions & 0 deletions packages/@markuplint/ml-core/src/ml-dom/helper/accname.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { MLDOMElement } from '../tokens';

import { parse } from '@markuplint/html-parser';

import { Document } from '../';
import { convertRuleset } from '../../';
import { dummySchemas } from '../../test';

import { getAccname } from './accname';
import { createNode } from './create-node';

function c(sourceCode: string) {
const ast = parse(sourceCode);
const astNode = ast.nodeList[0];
const ruleset = convertRuleset({});
const document = new Document(ast, ruleset, dummySchemas());
const node = createNode(astNode, document);
if (node.type === 'Element') {
return node;
}
throw new Error();
}

test('Get accessible name', () => {
expect(getAccname(c('<button>label</button>'))).toBe('label');
expect(getAccname(c('<div>text</div>'))).toBe('');
expect(getAccname(c('<div aria-label="label">text</div>'))).toBe('label');
expect(getAccname(c('<span aria-label="label">text</span>'))).toBe('label');
expect(getAccname(c('<img alt="alterative-text" />'))).toBe('alterative-text');
expect(getAccname(c('<img title="title" />'))).toBe('title');
expect(
getAccname(c('<div><label for="a">label</label><input id="a" /></div>').children[1] as MLDOMElement<any, any>),
).toBe('label');
});

test('Invisible element', () => {
expect(getAccname(c('<button>label</button>'))).toBe('label');
expect(getAccname(c('<button aria-disabled="true">label</button>'))).toBe('label');
expect(getAccname(c('<button aria-hidden="true">label</button>'))).toBe('');
expect(getAccname(c('<button hidden>label</button>'))).toBe('');
expect(getAccname(c('<button style="display: none">label</button>'))).toBe('label'); // Unsupport the style attribute yet.
});

/**
* @see https://www.w3.org/TR/accname-1.1/#ex-1-example-1-element1-id-el1-aria-labelledby-el3-element2-id-el2-aria-labelledby-el1-element3-id-el3-hello-element3
*/
test('accname-1.1 Expample 1', () => {
const complex = c(`<div>
<input id="el1" aria-labelledby="el3" />
<input id="el2" aria-labelledby="el1" />
<h1 id="el3"> hello </h1>
</div>`);
expect(getAccname(complex.children[0] as MLDOMElement<any, any>)).toBe('hello');
expect(getAccname(complex.children[1] as MLDOMElement<any, any>)).toBe('');
expect(getAccname(complex.children[2] as MLDOMElement<any, any>)).toBe('hello');
});

/**
* https://www.w3.org/TR/accname-1.1/#ex-2-example-2-h1-files-h1-ul-li-a-id-file_row1-href-files-documentation-pdf-documentation-pdf-a-span-role-button-tabindex-0-id-del_row1-aria-label-delete-aria-labelledby-del_row1-file_row1-span-li-li-a-id-file_row2-href-files-holidayletter-pdf-holidayletter-pdf-a-span-role-button-tabindex-0-id-del_row2-aria-label-delete-aria-labelledby-del_row2-file_row2-span-li-ul
*/
test('accname-1.1 Expample 2', () => {
const complex = c(`<ul>
<li>
<a id="file_row1" href="./files/Documentation.pdf">Documentation.pdf</a>
<span role="button" tabindex="0" id="del_row1" aria-label="Delete" aria-labelledby="del_row1 file_row1"></span>
</li>
<li>
<a id="file_row2" href="./files/HolidayLetter.pdf">HolidayLetter.pdf</a>
<span role="button" tabindex="0" id="del_row2" aria-label="Delete" aria-labelledby="del_row2 file_row2"></span>
</li>
</ul>`);
const spans = complex.querySelectorAll('span');
expect(getAccname(spans[0] as MLDOMElement<any, any>)).toBe('Delete Documentation.pdf');
expect(getAccname(spans[1] as MLDOMElement<any, any>)).toBe('Delete HolidayLetter.pdf');
});

/**
* https://www.w3.org/TR/accname-1.1/#ex-3-example-3-div-role-checkbox-aria-checked-false-flash-the-screen-span-role-textbox-aria-multiline-false-5-span-times-div
*/
test('accname-1.1 Expample 3', () => {
const complex = c(
'<div role="checkbox" aria-checked="false">Flash the screen <span role="textbox" aria-multiline="false"> 5 </span> times</div>',
);
expect(getAccname(complex)).toBe('Flash the screen 5 times');
});
18 changes: 18 additions & 0 deletions packages/@markuplint/ml-core/src/ml-dom/helper/accname.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type MLDOMAbstractElement from '../tokens/abstract-element';

import { computeAccessibleName } from 'dom-accessibility-api';

import { log } from '../../debug';

const accnameLog = log.extend('accname');

export function getAccname(el: MLDOMAbstractElement<any, any>) {
try {
// @ts-ignore
const name = computeAccessibleName(el);
return name;
} catch (err) {
accnameLog('Error: %O', err);
return '';
}
}
2 changes: 1 addition & 1 deletion packages/@markuplint/ml-core/src/ml-dom/helper/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function attributesToDebugMaps(attributes: (MLDOMAttribute | MLDOMPreprocessorSp
r.push(` ${tokenDebug(n.equal, 'equal')}`);
r.push(` ${tokenDebug(n.spacesAfterEqual, 'aE')}`);
r.push(` ${tokenDebug(n.startQuote, 'sQ')}`);
r.push(` ${tokenDebug(n.value, 'value')}`);
r.push(` ${tokenDebug(n.valueNode, 'value')}`);
r.push(` ${tokenDebug(n.endQuote, 'eQ')}`);
r.push(` isDirective: ${!!n.isDirective}`);
r.push(` isDynamicValue: ${!!n.isDynamicValue}`);
Expand Down
22 changes: 20 additions & 2 deletions packages/@markuplint/ml-core/src/ml-dom/tokens/abstract-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ContentModel } from '@markuplint/ml-spec';
import { getNS } from '@markuplint/ml-spec';

import { createSelector } from '../helper';
import { getAccname } from '../helper/accname';
import { syncWalk } from '../helper/walkers';

import MLDOMNode from './node';
Expand All @@ -17,6 +18,7 @@ export default abstract class MLDOMAbstractElement<
O = null,
E extends MLASTElement | MLASTOmittedElement = MLASTElement | MLASTOmittedElement,
> extends MLDOMNode<T, O, E> {
readonly nodeType = 1;
readonly nodeName: string;
readonly attributes: (MLDOMAttribute | MLDOMPreprocessorSpecificAttribute)[] = [];
readonly hasSpreadAttr: boolean = false;
Expand All @@ -42,6 +44,10 @@ export default abstract class MLDOMAbstractElement<
this.isInFragmentDocument = document.isFragment;
}

get tagName() {
return this.nodeName;
}

get childNodes(): AnonymousNode<T, O>[] {
const astChildren = this._astToken.childNodes || [];
return astChildren.map(node => this.nodeStore.getNode<typeof node, T, O>(node));
Expand Down Expand Up @@ -107,7 +113,7 @@ export default abstract class MLDOMAbstractElement<
const classList: string[] = [];
const classAttrs = this.getAttributeToken('class');
for (const classAttr of classAttrs) {
const value = classAttr.attrType === 'html-attr' ? classAttr.value.raw : classAttr.potentialValue;
const value = classAttr.attrType === 'html-attr' ? classAttr.value : classAttr.potentialValue;
classList.push(
...value
.split(/\s+/g)
Expand All @@ -126,6 +132,10 @@ export default abstract class MLDOMAbstractElement<
return this.#fixedNodeName;
}

get textContent(): string {
return this.childNodes.map(child => child.textContent || '').join('');
}

querySelectorAll(selector: string) {
const matchedNodes: (MLDOMElement<T, O> | MLDOMText<T, O>)[] = [];
const selecor = createSelector(selector);
Expand Down Expand Up @@ -164,12 +174,16 @@ export default abstract class MLDOMAbstractElement<
return attrs;
}

getAttributeNode(attrName: string) {
return this.getAttributeToken(attrName)[0] || null;
}

getAttribute(attrName: string) {
attrName = attrName.toLowerCase();
for (const attr of this.attributes) {
if (attr.potentialName === attrName) {
if (attr.attrType === 'html-attr') {
return attr.value ? attr.value.raw : null;
return attr.value ? attr.value : null;
} else {
return attr.potentialValue;
}
Expand Down Expand Up @@ -234,6 +248,10 @@ export default abstract class MLDOMAbstractElement<
return false;
}

getAccessibleName(): string {
return getAccname(this);
}

toNormalizeString(): string {
if (this.#normalizedString) {
return this.#normalizedString;
Expand Down
22 changes: 14 additions & 8 deletions packages/@markuplint/ml-core/src/ml-dom/tokens/attribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import type { MLASTHTMLAttr, MLToken } from '@markuplint/ml-ast';
import MLDOMToken from './token';

export default class MLDOMAttribute extends MLDOMToken<MLASTHTMLAttr> {
readonly nodeType = 2;
readonly attrType = 'html-attr';
readonly name: MLDOMToken<MLToken>;
readonly spacesBeforeName: MLDOMToken<MLToken>;
readonly spacesBeforeEqual: MLDOMToken<MLToken>;
readonly equal: MLDOMToken<MLToken>;
readonly spacesAfterEqual: MLDOMToken<MLToken>;
readonly startQuote: MLDOMToken<MLToken>;
readonly value: MLDOMToken<MLToken>;
readonly valueNode: MLDOMToken<MLToken>;
readonly endQuote: MLDOMToken<MLToken>;
readonly isDynamicValue?: true;
readonly isDirective?: true;
Expand All @@ -27,7 +28,7 @@ export default class MLDOMAttribute extends MLDOMToken<MLASTHTMLAttr> {
this.equal = new MLDOMToken(this._astToken.equal);
this.spacesAfterEqual = new MLDOMToken(this._astToken.spacesAfterEqual);
this.startQuote = new MLDOMToken(this._astToken.startQuote);
this.value = new MLDOMToken(this._astToken.value);
this.valueNode = new MLDOMToken(this._astToken.value);
this.endQuote = new MLDOMToken(this._astToken.endQuote);
this.isDynamicValue = astToken.isDynamicValue;
this.isDirective = astToken.isDirective;
Expand All @@ -43,7 +44,7 @@ export default class MLDOMAttribute extends MLDOMToken<MLASTHTMLAttr> {
raw.push(this.equal.raw);
raw.push(this.spacesAfterEqual.raw);
raw.push(this.startQuote.raw);
raw.push(this.value.raw);
raw.push(this.valueNode.raw);
raw.push(this.endQuote.raw);
}
return raw.join('');
Expand Down Expand Up @@ -73,6 +74,11 @@ export default class MLDOMAttribute extends MLDOMToken<MLASTHTMLAttr> {
return this.endQuote.endCol;
}

get value() {
const value = this.getValue();
return value.raw;
}

getName() {
return {
line: this.name.startLine,
Expand All @@ -84,10 +90,10 @@ export default class MLDOMAttribute extends MLDOMToken<MLASTHTMLAttr> {

getValue() {
return {
line: this.value.startLine,
col: this.value.startCol,
potential: this.value.raw,
raw: this.value.raw,
line: this.valueNode.startLine,
col: this.valueNode.startCol,
potential: this.valueNode.raw,
raw: this.valueNode.raw,
};
}

Expand All @@ -100,7 +106,7 @@ export default class MLDOMAttribute extends MLDOMToken<MLASTHTMLAttr> {
this.name.originRaw +
this.equal.originRaw +
this.startQuote.originRaw +
this.value.originRaw +
this.valueNode.originRaw +
this.endQuote.originRaw
);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/@markuplint/ml-core/src/ml-dom/tokens/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,10 @@ export default class MLDOMComment<T extends RuleConfigValue, O = null>
extends MLDOMNode<T, O, MLASTComment>
implements IMLDOMComment
{
readonly nodeType = 8;
readonly type = 'Comment';

get textContent() {
return this.raw;
}
}
5 changes: 5 additions & 0 deletions packages/@markuplint/ml-core/src/ml-dom/tokens/doctype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default class MLDOMDoctype<T extends RuleConfigValue, O = null>
extends MLDOMNode<T, O, MLASTNode>
implements IMLDOMDoctype
{
readonly nodeType = 10;
readonly type = 'Doctype';

#name: string;
Expand All @@ -33,4 +34,8 @@ export default class MLDOMDoctype<T extends RuleConfigValue, O = null>
get systemId() {
return this.#systemId;
}

get textContent() {
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default class MLDOMElementCloseTag<T extends RuleConfigValue, O = null>
extends MLDOMNode<T, O, MLASTElementCloseTag>
implements IMLDOMElementCloseTag
{
readonly nodeType = 1;
readonly type = 'ElementCloseTag';
readonly nodeName: string;
readonly startTag: MLDOMElement<T, O>;
Expand Down
Loading

0 comments on commit dfc689e

Please sign in to comment.