Skip to content

Commit

Permalink
[Refactor] use fromEntries, flatMap, etc; better use iteration methods
Browse files Browse the repository at this point in the history
  • Loading branch information
ljharb committed Jan 9, 2023
1 parent 89f766c commit 3d77c84
Show file tree
Hide file tree
Showing 14 changed files with 110 additions and 164 deletions.
35 changes: 17 additions & 18 deletions __mocks__/genInteractives.js
Expand Up @@ -4,6 +4,8 @@

import { dom, roles } from 'aria-query';
import includes from 'array-includes';
import fromEntries from 'object.fromentries';

import JSXAttributeMock from './JSXAttributeMock';
import JSXElementMock from './JSXElementMock';

Expand Down Expand Up @@ -115,13 +117,7 @@ const nonInteractiveElementsMap: {[string]: Array<{[string]: string}>} = {
ul: [],
};

const indeterminantInteractiveElementsMap = domElements.reduce(
(accumulator: { [key: string]: Array<any> }, name: string): { [key: string]: Array<any> } => ({
...accumulator,
[name]: [],
}),
{},
);
const indeterminantInteractiveElementsMap: { [key: string]: Array<any> } = fromEntries(domElements.map((name: string) => [name, []]));

Object.keys(interactiveElementsMap)
.concat(Object.keys(nonInteractiveElementsMap))
Expand All @@ -138,22 +134,25 @@ const interactiveRoles = []
// aria-activedescendant, thus in practice we treat it as a widget.
'toolbar',
)
.filter((role) => !roles.get(role).abstract)
.filter((role) => roles.get(role).superClass.some((klasses) => includes(klasses, 'widget')));
.filter((role) => (
!roles.get(role).abstract
&& roles.get(role).superClass.some((klasses) => includes(klasses, 'widget'))
));

const nonInteractiveRoles = roleNames
.filter((role) => !roles.get(role).abstract)
.filter((role) => !roles.get(role).superClass.some((klasses) => includes(klasses, 'widget')))
// 'toolbar' does not descend from widget, but it does support
// aria-activedescendant, thus in practice we treat it as a widget.
.filter((role) => !includes(['toolbar'], role));
.filter((role) => (
!roles.get(role).abstract
&& !roles.get(role).superClass.some((klasses) => includes(klasses, 'widget'))

// 'toolbar' does not descend from widget, but it does support
// aria-activedescendant, thus in practice we treat it as a widget.
&& !includes(['toolbar'], role)
));

export function genElementSymbol(openingElement: Object): string {
return (
openingElement.name.name + (openingElement.attributes.length > 0
? `${openingElement.attributes
.map((attr) => `[${attr.name.name}="${attr.value.value}"]`)
.join('')}`
? `${openingElement.attributes.map((attr) => `[${attr.name.name}="${attr.value.value}"]`).join('')}`
: ''
)
);
Expand All @@ -172,7 +171,7 @@ export function genInteractiveElements(): Array<JSXElementMockType> {
}

export function genInteractiveRoleElements(): Array<JSXElementMockType> {
return [...interactiveRoles, 'button article', 'fakerole button article'].map((value): JSXElementMockType => JSXElementMock(
return interactiveRoles.concat('button article', 'fakerole button article').map((value): JSXElementMockType => JSXElementMock(
'div',
[JSXAttributeMock('role', value)],
));
Expand Down
9 changes: 5 additions & 4 deletions __tests__/__util__/ruleOptionsMapperFactory.js
Expand Up @@ -2,6 +2,10 @@
* @flow
*/

import entries from 'object.entries';
import flatMap from 'array.prototype.flatmap';
import fromEntries from 'object.fromentries';

type ESLintTestRunnerTestCase = {
code: string,
errors: ?Array<{ message: string, type: string }>,
Expand All @@ -21,10 +25,7 @@ export default function ruleOptionsMapperFactory(ruleOptions: Array<mixed> = [])
code,
errors,
// Flatten the array of objects in an array of one object.
options: (options || []).concat(ruleOptions).reduce((acc, item) => [{
...acc[0],
...item,
}], [{}]),
options: [fromEntries(flatMap((options || []).concat(ruleOptions), (item) => entries(item)))],
parserOptions,
settings,
};
Expand Down
4 changes: 2 additions & 2 deletions __tests__/src/rules/aria-unsupported-elements-test.js
Expand Up @@ -50,7 +50,7 @@ const ariaValidityTests = domElements.map((element) => {

// Generate invalid test cases.
const invalidRoleValidityTests = domElements
.filter((element) => Boolean(dom.get(element).reserved))
.filter((element) => dom.get(element).reserved)
.map((reservedElem) => ({
code: `<${reservedElem} role {...props} />`,
errors: [errorMessage('role')],
Expand All @@ -61,7 +61,7 @@ const invalidRoleValidityTests = domElements
});

const invalidAriaValidityTests = domElements
.filter((element) => Boolean(dom.get(element).reserved))
.filter((element) => dom.get(element).reserved)
.map((reservedElem) => ({
code: `<${reservedElem} aria-hidden aria-role="none" {...props} />`,
errors: [errorMessage('aria-hidden')],
Expand Down
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -72,6 +72,7 @@
"@babel/runtime": "^7.20.7",
"aria-query": "^5.1.3",
"array-includes": "^3.1.6",
"array.prototype.flatmap": "^1.3.1",
"ast-types-flow": "^0.0.7",
"axe-core": "^4.6.2",
"axobject-query": "^3.1.1",
Expand All @@ -81,6 +82,8 @@
"jsx-ast-utils": "^3.3.3",
"language-tags": "=1.0.5",
"minimatch": "^3.1.2",
"object.entries": "^1.1.6",
"object.fromentries": "^2.0.6",
"semver": "^6.3.0"
},
"peerDependencies": {
Expand Down
12 changes: 6 additions & 6 deletions src/rules/alt-text.js
Expand Up @@ -12,6 +12,8 @@ import {
getPropValue,
getLiteralPropValue,
} from 'jsx-ast-utils';
import flatMap from 'array.prototype.flatmap';

import { generateObjSchema, arraySchema } from '../util/schemas';
import getElementType from '../util/getElementType';
import hasAccessibleChild from '../util/hasAccessibleChild';
Expand Down Expand Up @@ -204,12 +206,10 @@ export default {
// Elements to validate for alt text.
const elementOptions = options.elements || DEFAULT_ELEMENTS;
// Get custom components for just the elements that will be tested.
const customComponents = elementOptions
.map((element) => options[element])
.reduce(
(components, customComponentsForElement) => components.concat(customComponentsForElement || []),
[],
);
const customComponents = flatMap(
elementOptions,
(element) => options[element],
);
const typesToValidate = new Set(
[].concat(
customComponents,
Expand Down
20 changes: 9 additions & 11 deletions src/rules/anchor-is-valid.js
Expand Up @@ -64,16 +64,12 @@ export default ({

const propOptions = options.specialLink || [];
const propsToValidate = ['href'].concat(propOptions);
const values = propsToValidate
.map((prop) => getProp(node.attributes, prop))
.map((prop) => getPropValue(prop));
const values = propsToValidate.map((prop) => getPropValue(getProp(node.attributes, prop)));
// Checks if any actual or custom href prop is provided.
const hasAnyHref = values
.filter((value) => value === undefined || value === null).length !== values.length;
const hasAnyHref = values.some((value) => value != null);
// Need to check for spread operator as props can be spread onto the element
// leading to an incorrect validation error.
const hasSpreadOperator = attributes
.filter((prop) => prop.type === 'JSXSpreadAttribute').length > 0;
const hasSpreadOperator = attributes.some((prop) => prop.type === 'JSXSpreadAttribute');
const onClick = getProp(attributes, 'onClick');

// When there is no href at all, specific scenarios apply:
Expand All @@ -99,10 +95,12 @@ export default ({

// Hrefs have been found, now check for validity.
const invalidHrefValues = values
.filter((value) => value !== undefined && value !== null)
.filter((value) => (typeof value === 'string' && (
!value.length || value === '#' || /^\W*?javascript:/.test(value)
)));
.filter((value) => (
value != null
&& (typeof value === 'string' && (
!value.length || value === '#' || /^\W*?javascript:/.test(value)
))
));
if (invalidHrefValues.length !== 0) {
// If an onClick is found it should be a button, otherwise it is an invalid link.
if (onClick && activeAspects.preferButton) {
Expand Down
7 changes: 4 additions & 3 deletions src/rules/interactive-supports-focus.js
Expand Up @@ -36,9 +36,10 @@ import getTabIndex from '../util/getTabIndex';
// ----------------------------------------------------------------------------

const schema = generateObjSchema({
tabbable: enumArraySchema([...roles.keys()]
.filter((name) => !roles.get(name).abstract)
.filter((name) => roles.get(name).superClass.some((klasses) => includes(klasses, 'widget')))),
tabbable: enumArraySchema([...roles.keys()].filter((name) => (
!roles.get(name).abstract
&& roles.get(name).superClass.some((klasses) => includes(klasses, 'widget'))
))),
});
const domElements = [...dom.keys()];

Expand Down
6 changes: 4 additions & 2 deletions src/rules/media-has-caption.js
Expand Up @@ -10,6 +10,8 @@

import type { JSXElement, JSXOpeningElement, Node } from 'ast-types-flow';
import { getProp, getLiteralPropValue } from 'jsx-ast-utils';
import flatMap from 'array.prototype.flatmap';

import type { ESLintConfig, ESLintContext, ESLintVisitorSelectorConfig } from '../../flow/eslint';
import { generateObjSchema, arraySchema } from '../util/schemas';
import getElementType from '../util/getElementType';
Expand All @@ -26,8 +28,8 @@ const schema = generateObjSchema({

const isMediaType = (context, type) => {
const options = context.options[0] || {};
return MEDIA_TYPES.map((mediaType) => options[mediaType])
.reduce((types, customComponent) => types.concat(customComponent), MEDIA_TYPES)
return MEDIA_TYPES
.concat(flatMap(MEDIA_TYPES, (mediaType) => options[mediaType]))
.some((typeToCheck) => typeToCheck === type);
};

Expand Down
14 changes: 8 additions & 6 deletions src/util/getSuggestion.js
@@ -1,4 +1,5 @@
import editDistance from 'damerau-levenshtein';
import fromEntries from 'object.fromentries';

// Minimum edit distance to be considered a good suggestion.
const THRESHOLD = 2;
Expand All @@ -8,12 +9,13 @@ const THRESHOLD = 2;
* to return.
*/
export default function getSuggestion(word, dictionary = [], limit = 2) {
const distances = dictionary.reduce((suggestions, dictionaryWord) => {
const distance = editDistance(word.toUpperCase(), dictionaryWord.toUpperCase());
const { steps } = distance;
suggestions[dictionaryWord] = steps; // eslint-disable-line
return suggestions;
}, {});
const distances = fromEntries(
dictionary.map((dictionaryWord) => {
const distance = editDistance(word.toUpperCase(), dictionaryWord.toUpperCase());
const { steps } = distance;
return [dictionaryWord, steps];
}),
);

return Object.keys(distances)
.filter((suggestion) => distances[suggestion] <= THRESHOLD)
Expand Down
4 changes: 2 additions & 2 deletions src/util/hasAccessibleChild.js
Expand Up @@ -8,10 +8,10 @@ export default function hasAccessibleChild(node: JSXElement, elementType: (JSXOp
return node.children.some((child: Node) => {
switch (child.type) {
case 'Literal':
return Boolean(child.value);
return !!child.value;
// $FlowFixMe JSXText is missing in ast-types-flow
case 'JSXText':
return Boolean(child.value);
return !!child.value;
case 'JSXElement':
return !isHiddenFromScreenReader(
elementType(child.openingElement),
Expand Down
65 changes: 18 additions & 47 deletions src/util/isInteractiveElement.js
Expand Up @@ -12,6 +12,7 @@ import {
elementAXObjects,
} from 'axobject-query';
import includes from 'array-includes';
import flatMap from 'array.prototype.flatmap';
import attributesComparator from './attributesComparator';

const domKeys = [...dom.keys()];
Expand Down Expand Up @@ -39,8 +40,8 @@ const interactiveRoles = new Set(roleKeys
const role = roles.get(name);
return (
!role.abstract
// The `progressbar` is descended from `widget`, but in practice, its
// value is always `readonly`, so we treat it as a non-interactive role.
// The `progressbar` is descended from `widget`, but in practice, its
// value is always `readonly`, so we treat it as a non-interactive role.
&& name !== 'progressbar'
&& role.superClass.some((classes) => includes(classes, 'widget'))
);
Expand All @@ -50,50 +51,23 @@ const interactiveRoles = new Set(roleKeys
'toolbar',
));

const nonInteractiveElementRoleSchemas = elementRoleEntries
.reduce((
accumulator,
[
elementSchema,
roleSet,
],
) => {
if ([...roleSet].every((role): boolean => nonInteractiveRoles.has(role))) {
accumulator.push(elementSchema);
}
return accumulator;
}, []);
const nonInteractiveElementRoleSchemas = flatMap(
elementRoleEntries,
([elementSchema, roleSet]) => ([...roleSet].every((role): boolean => nonInteractiveRoles.has(role)) ? [elementSchema] : []),
);

const interactiveElementRoleSchemas = elementRoleEntries
.reduce((
accumulator,
[
elementSchema,
roleSet,
],
) => {
if ([...roleSet].some((role): boolean => interactiveRoles.has(role))) {
accumulator.push(elementSchema);
}
return accumulator;
}, []);
const interactiveElementRoleSchemas = flatMap(
elementRoleEntries,
([elementSchema, roleSet]) => ([...roleSet].some((role): boolean => interactiveRoles.has(role)) ? [elementSchema] : []),
);

const interactiveAXObjects = new Set([...AXObjects.keys()]
.filter((name) => AXObjects.get(name).type === 'widget'));

const interactiveElementAXObjectSchemas = [...elementAXObjects]
.reduce((
accumulator,
[
elementSchema,
AXObjectSet,
],
) => {
if ([...AXObjectSet].every((role): boolean => interactiveAXObjects.has(role))) {
accumulator.push(elementSchema);
}
return accumulator;
}, []);
const interactiveElementAXObjectSchemas = flatMap(
[...elementAXObjects],
([elementSchema, AXObjectSet]) => ([...AXObjectSet].every((role): boolean => interactiveAXObjects.has(role)) ? [elementSchema] : []),
);

function checkIsInteractiveElement(tagName, attributes): boolean {
function elementSchemaMatcher(elementSchema) {
Expand All @@ -104,21 +78,18 @@ function checkIsInteractiveElement(tagName, attributes): boolean {
}
// Check in elementRoles for inherent interactive role associations for
// this element.
const isInherentInteractiveElement = interactiveElementRoleSchemas
.some(elementSchemaMatcher);
const isInherentInteractiveElement = interactiveElementRoleSchemas.some(elementSchemaMatcher);
if (isInherentInteractiveElement) {
return true;
}
// Check in elementRoles for inherent non-interactive role associations for
// this element.
const isInherentNonInteractiveElement = nonInteractiveElementRoleSchemas
.some(elementSchemaMatcher);
const isInherentNonInteractiveElement = nonInteractiveElementRoleSchemas.some(elementSchemaMatcher);
if (isInherentNonInteractiveElement) {
return false;
}
// Check in elementAXObjects for AX Tree associations for this element.
const isInteractiveAXElement = interactiveElementAXObjectSchemas
.some(elementSchemaMatcher);
const isInteractiveAXElement = interactiveElementAXObjectSchemas.some(elementSchemaMatcher);
if (isInteractiveAXElement) {
return true;
}
Expand Down

0 comments on commit 3d77c84

Please sign in to comment.