diff --git a/.circleci/config.yml b/.circleci/config.yml index 0134cd0db7..2fbc6acd7f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -74,7 +74,7 @@ jobs: - checkout - <<: *restore_dependency_cache_unix - run: npm run test - + # Run the test suite in IE in windows test_win: <<: *defaults @@ -83,7 +83,7 @@ jobs: - checkout # npm i or restore cache - <<: *restore_dependency_cache_win - # install selenium + # install selenium - run: | choco install selenium-ie-driver --version 3.141.5 export PATH=/c/tools/selenium:$PATH @@ -91,8 +91,8 @@ jobs: # build `axe` - run: npm run build # get fixtures ready for running tests - - run: npx grunt testconfig - - run: npx grunt fixture + - run: npx grunt testconfig + - run: npx grunt fixture # run IE webdriver tests - run: npx grunt connect test-webdriver:ie # test examples @@ -107,6 +107,7 @@ jobs: steps: - checkout - <<: *restore_dependency_cache_unix + - run: npm run build - run: npm run test:examples # Test locale files @@ -191,7 +192,7 @@ workflows: requires: - dependencies_unix - lint - # Run IE/ Windows test on all commits + # Run IE/ Windows test on all commits - test_win: requires: - dependencies_win diff --git a/axe.d.ts b/axe.d.ts index 88bc95a725..26e4f2f247 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -3,9 +3,9 @@ // Definitions by: Marcy Sutton declare namespace axe { - type ImpactValue = 'minor' | 'moderate' | 'serious' | 'critical'; + type ImpactValue = 'minor' | 'moderate' | 'serious' | 'critical' | null; - type TagValue = 'wcag2a' | 'wcag2aa' | 'section508' | 'best-practice' | 'wcag21a' | 'wcag21aa'; + type TagValue = string; type ReporterVersion = 'v1' | 'v2' | 'raw' | 'raw-env' | 'no-passes'; diff --git a/build/rule-generator/get-files-metadata.js b/build/rule-generator/get-files-metadata.js index 08bdd4a0a9..3bfe059b12 100644 --- a/build/rule-generator/get-files-metadata.js +++ b/build/rule-generator/get-files-metadata.js @@ -1,5 +1,15 @@ const directories = require('./directories'); +/** + * Helper to convert a given string to camel case (split by hyphens if any) + * @param {String} str given string to be camel cased + */ +const camelCase = str => { + return str.replace(/-([a-z])/g, g => { + return g[1].toUpperCase(); + }); +}; + /** * Get meta data for the file to be created as RULE Specification * @method getRuleSpecFileMeta @@ -16,7 +26,7 @@ const getRuleSpecFileMeta = (ruleName, ruleHasMatches, ruleChecks) => { id: `${ruleName}`, selector: '', ...(ruleHasMatches && { - matches: `${ruleName}-matches.js` + matches: `${ruleName}-matches` }), tags: [], metadata: { @@ -57,11 +67,17 @@ const getRuleMatchesFileMeta = ( let files = []; if (ruleHasMatches) { + const fnName = `${camelCase(ruleName)}Matches`; const ruleMatchesJs = { name: `${ruleName}-matches.js`, content: ` - // TODO: Filter node(s) - return node; + // TODO: Filter node(s) + + function ${fnName}(node, virtualNode) { + return node + } + + export default ${fnName} `, dir: directories.rules }; @@ -98,7 +114,7 @@ const getCheckSpecFileMeta = (name, dir) => { content: JSON.stringify( { id: `${name}`, - evaluate: `${name}.js`, + evaluate: `${name}-evaluate`, metadata: { impact: '', messages: { @@ -123,11 +139,15 @@ const getCheckSpecFileMeta = (name, dir) => { * @returns {Object} meta data of file */ const getCheckJsFileMeta = (name, dir) => { + const fnName = `${camelCase(name)}Evaluate`; return { - name: `${name}.js`, + name: `${name}-evaluate.js`, content: ` // TODO: Logic for check - return true; + function ${fnName}(node, options, virtualNode) { + return true + } + export default ${fnName}; `, dir }; diff --git a/doc/examples/jsdom/test/a11y.js b/doc/examples/jsdom/test/a11y.js index cfa4688a07..a3c8b4119d 100644 --- a/doc/examples/jsdom/test/a11y.js +++ b/doc/examples/jsdom/test/a11y.js @@ -20,20 +20,6 @@ describe('axe', () => { `); - global.document = window.document; - global.window = window; - - // needed by axios lib/helpers/isURLSameOrigin.js - global.navigator = window.navigator; - - // needed by axe /lib/core/public/run.js - global.Node = window.Node; - global.NodeList = window.NodeList; - - // needed by axe /lib/core/base/context.js - global.Element = window.Element; - global.Document = window.Document; - const axe = require('axe-core'); const config = { rules: { diff --git a/doc/examples/test-examples.js b/doc/examples/test-examples.js index 87d2576149..6495a2c756 100644 --- a/doc/examples/test-examples.js +++ b/doc/examples/test-examples.js @@ -8,8 +8,16 @@ const exampleDirs = readdirSync(__dirname) const config = { stdio: 'inherit', shell: true }; // run npm install in parallel -function install(dir) { - return execa('npm install', { cwd: dir, ...config }); +async function install(dir) { + await execa('npm install', { cwd: dir, ...config }); + + // override the package version of axe-core with the local version. + // this allows the examples to stay examples while allowing us to + // test them against our changes + return await execa('npm install --no-save file:..\\/..\\/..\\/', { + cwd: dir, + ...config + }); } // run tests synchronously so we can see which one threw an error diff --git a/lib/commons/aria/get-role.js b/lib/commons/aria/get-role.js index b729d65932..e2af68102c 100644 --- a/lib/commons/aria/get-role.js +++ b/lib/commons/aria/get-role.js @@ -1,10 +1,118 @@ import getExplicitRole from './get-explicit-role'; import getImplicitRole from './implicit-role'; +import lookupTable from './lookup-table'; +import isFocusable from '../dom/is-focusable'; import { getNodeFromTree } from '../../core/utils'; import AbstractVirtuaNode from '../../core/base/virtual-node/abstract-virtual-node'; +// when an element inherits the presentational role from a parent +// is not defined in the spec, but through testing it seems to be +// when a specific HTML parent relationship is required and that +// parent has `role=presentation`, then the child inherits the +// role (i.e. table, ul, dl). Further testing has shown that +// intermediate elements (such as divs) break this chain only in +// Chrome. +// +// Also, any nested structure chains reset the role (so two nested +// lists with the topmost list role=none will not cause the nested +// list to inherit the role=none). +// +// from Scott O'Hara: +// +// "the expectation for me, in standard html is that element +// structures that require specific parent/child relationships, +// if the parent is set to presentational that should set the +// children to presentational. ala, tables and lists." +// "but outside of those specific constructs, i would not expect +// role=presentation to do anything to child element roles" +const inheritsPresentationChain = { + // valid parent elements, any other element will prevent any + // children from inheriting a presentational role from a valid + // ancestor + td: ['tr'], + th: ['tr'], + tr: ['thead', 'tbody', 'tfoot', 'table'], + thead: ['table'], + tbody: ['table'], + tfoot: ['table'], + li: ['ol', 'ul'], + // dts and dds can be wrapped in divs and the div will pass through + // the presentation role + dt: ['dl', 'div'], + dd: ['dl', 'div'], + div: ['dl'] +}; + +// role presentation inheritance. +// Source: https://www.w3.org/TR/wai-aria-1.1/#conflict_resolution_presentation_none +function getInheritedRole(vNode, explicitRoleOptions) { + const parentNodeNames = inheritsPresentationChain[vNode.props.nodeName]; + if (!parentNodeNames) { + return null; + } + + // if we can't look at the parent then we can't know if the node + // inherits the presentational role or not + if (!vNode.parent) { + throw new ReferenceError( + 'Cannot determine role presentational inheritance of a required parent outside the current scope.' + ); + } + + // parent is not a valid ancestor that can inherit presentation + if (!parentNodeNames.includes(vNode.parent.props.nodeName)) { + return null; + } + + const parentRole = getExplicitRole(vNode.parent, explicitRoleOptions); + if ( + ['none', 'presentation'].includes(parentRole) && + !hasConflictResolution(vNode.parent) + ) { + return parentRole; + } + + // an explicit role of anything other than presentational will + // prevent any children from inheriting a presentational role + // from a valid ancestor + if (parentRole) { + return null; + } + + return getInheritedRole(vNode.parent, explicitRoleOptions); +} + +function resolveImplicitRole(vNode, explicitRoleOptions) { + const implicitRole = getImplicitRole(vNode); + + if (!implicitRole) { + return null; + } + + const presentationalRole = getInheritedRole(vNode, explicitRoleOptions); + if (presentationalRole) { + return presentationalRole; + } + + return implicitRole; +} + +// role conflict resolution +// note: Chrome returns a list with resolved role as "generic" +// instead of as a list +// (e.g. ) +// we will return it as a list as that is the best option. +// Source: https://www.w3.org/TR/wai-aria-1.1/#conflict_resolution_presentation_none +// See also: https://github.com/w3c/aria/issues/1270 +function hasConflictResolution(vNode) { + const hasGlobalAria = lookupTable.globalAttributes.some(attr => + vNode.hasAttr(attr) + ); + return hasGlobalAria || isFocusable(vNode.actualNode); +} + /** - * Return the semantic role of an element + * Return the semantic role of an element. * * @method getRole * @memberof axe.commons.aria @@ -19,20 +127,31 @@ import AbstractVirtuaNode from '../../core/base/virtual-node/abstract-virtual-no * * @deprecated noImplicit option is deprecated. Use aria.getExplicitRole instead. */ -function getRole(node, { noImplicit, fallback, abstracts, dpub } = {}) { +function getRole(node, { noImplicit, ...explicitRoleOptions } = {}) { const vNode = node instanceof AbstractVirtuaNode ? node : getNodeFromTree(node); if (vNode.props.nodeType !== 1) { return null; } - const explicitRole = getExplicitRole(vNode, { fallback, abstracts, dpub }); - // Get the implicit role, if permitted - if (!explicitRole && !noImplicit) { - return getImplicitRole(vNode); + const explicitRole = getExplicitRole(vNode, explicitRoleOptions); + + if (!explicitRole) { + return noImplicit ? null : resolveImplicitRole(vNode, explicitRoleOptions); + } + + if (!['presentation', 'none'].includes(explicitRole)) { + return explicitRole; + } + + if (hasConflictResolution(vNode)) { + // return null if there is a conflict resolution but no implicit + // has been set as the explicit role is not the true role + return noImplicit ? null : resolveImplicitRole(vNode, explicitRoleOptions); } - return explicitRole || null; + // role presentation or none and no conflict resolution + return explicitRole; } export default getRole; diff --git a/lib/commons/aria/lookup-table.js b/lib/commons/aria/lookup-table.js index 754cca91b6..126be740c2 100644 --- a/lib/commons/aria/lookup-table.js +++ b/lib/commons/aria/lookup-table.js @@ -4,6 +4,7 @@ import idrefs from '../dom/idrefs'; import isColumnHeader from '../table/is-column-header'; import isRowHeader from '../table/is-row-header'; import sanitize from '../text/sanitize'; +import isFocusable from '../dom/is-focusable'; import { closest } from '../../core/utils'; const isNull = value => value === null; @@ -1587,7 +1588,8 @@ lookupTable.role = { 'aria-required', 'aria-expanded', 'aria-readonly', - 'aria-errormessage' + 'aria-errormessage', + 'aria-orientation' ] }, owned: { @@ -1597,7 +1599,7 @@ lookupTable.role = { context: null, unsupported: false, allowedElements: { - nodeName: ['ol', 'ul'] + nodeName: ['ol', 'ul', 'fieldset'] } }, range: { @@ -2170,7 +2172,19 @@ lookupTable.implicitHtmlRole = { }, hr: 'separator', img: vNode => { - return vNode.hasAttr('alt') && !vNode.attr('alt') ? null : 'img'; + // an images role is considered implicitly presentation if the + // alt attribute is empty. But that shouldn't be the case if it + // has global aria attributes or is focusable, so we need to + // override the role back to `img` + // e.g. + const emptyAlt = vNode.hasAttr('alt') && !vNode.attr('alt'); + const hasGlobalAria = lookupTable.globalAttributes.find(attr => + vNode.hasAttr(attr) + ); + + return emptyAlt && !hasGlobalAria && !isFocusable(vNode.actualNode) + ? 'presentation' + : 'img'; }, input: vNode => { // Source: https://www.w3.org/TR/html52/sec-forms.html#suggestions-source-element @@ -2204,6 +2218,9 @@ lookupTable.implicitHtmlRole = { return !suggestionsSourceElement ? 'searchbox' : 'combobox'; } }, + // Note: if an li (or some other elms) do not have a required + // parent, Firefox ignores the implicit semantic role and treats + // it as a generic text. li: 'listitem', main: 'main', math: 'math', diff --git a/lib/commons/dom/get-text-element-stack.js b/lib/commons/dom/get-text-element-stack.js index f3547f7831..50b4435b4d 100644 --- a/lib/commons/dom/get-text-element-stack.js +++ b/lib/commons/dom/get-text-element-stack.js @@ -1,3 +1,4 @@ +import getElementStack from './get-element-stack'; import { createGrid, getRectStack } from './get-rect-stack'; import sanitize from '../text/sanitize'; @@ -23,6 +24,17 @@ function getTextElementStack(node) { return []; } + // if the element is using text truncation we need to get the rect of + // the element rather than look at the text node rects as they will return + // the full width of the text node before truncation (which can go off the + // screen) + // @see https://github.com/dequelabs/axe-core/issues/2178 + const whiteSpace = vNode.getComputedStylePropertyValue('white-space'); + const overflow = vNode.getComputedStylePropertyValue('overflow'); + if (whiteSpace === 'nowrap' && overflow === 'hidden') { + return [getElementStack(node)]; + } + // for code blocks that use syntax highlighting, you can get a ton of client // rects (See https://github.com/dequelabs/axe-core/issues/1985). they use // a mixture of text nodes and other nodes (which will contain their own text diff --git a/lib/commons/dom/is-node.js b/lib/commons/dom/is-node.js index f832b7f8c1..d4f7c96062 100644 --- a/lib/commons/dom/is-node.js +++ b/lib/commons/dom/is-node.js @@ -9,7 +9,7 @@ */ function isNode(element) { 'use strict'; - return element instanceof Node; + return element instanceof window.Node; } export default isNode; diff --git a/lib/commons/dom/visually-contains.js b/lib/commons/dom/visually-contains.js index f4c52cd50f..47f5c651be 100644 --- a/lib/commons/dom/visually-contains.js +++ b/lib/commons/dom/visually-contains.js @@ -68,8 +68,8 @@ function contains(node, parent) { style.overflow === 'scroll' || style.overflow === 'auto' || style.overflow === 'hidden' || - parent instanceof HTMLBodyElement || - parent instanceof HTMLHtmlElement + parent instanceof window.HTMLBodyElement || + parent instanceof window.HTMLHtmlElement ); } diff --git a/lib/commons/dom/visually-overlaps.js b/lib/commons/dom/visually-overlaps.js index bc8ba9089d..76dc4b4e52 100644 --- a/lib/commons/dom/visually-overlaps.js +++ b/lib/commons/dom/visually-overlaps.js @@ -35,8 +35,8 @@ function visuallyOverlaps(rect, parent) { return ( style.overflow === 'scroll' || style.overflow === 'auto' || - parent instanceof HTMLBodyElement || - parent instanceof HTMLHtmlElement + parent instanceof window.HTMLBodyElement || + parent instanceof window.HTMLHtmlElement ); } diff --git a/lib/commons/matches/from-primative.js b/lib/commons/matches/from-primative.js index 5ca4cbbfdc..ff1124778a 100644 --- a/lib/commons/matches/from-primative.js +++ b/lib/commons/matches/from-primative.js @@ -6,6 +6,7 @@ * match.fromPrimative('foo', ['foo', 'bar']) // true, string is included * match.fromPrimative('foo', /foo/) // true, string matches regex * match.fromPrimative('foo', str => str.toUpperCase() === 'FOO') // true, function return is truthy + * match.fromPrimative('foo', '/foo/') // true, string matches regex string * ``` * * @private @@ -24,6 +25,11 @@ function fromPrimative(someString, matcher) { if (matcher instanceof RegExp) { return matcher.test(someString); } + // matcher starts and ends with "/" + if (/^\/.*\/$/.test(matcher)) { + const pattern = matcher.substring(1, matcher.length - 1); + return new RegExp(pattern).test(someString); + } return matcher === someString; } diff --git a/lib/commons/table/get-scope.js b/lib/commons/table/get-scope.js index ad70a46688..5fe9fc108e 100644 --- a/lib/commons/table/get-scope.js +++ b/lib/commons/table/get-scope.js @@ -15,7 +15,7 @@ function getScope(cell) { var role = cell.getAttribute('role'); if ( - cell instanceof Element === false || + cell instanceof window.Element === false || ['TD', 'TH'].indexOf(cell.nodeName.toUpperCase()) === -1 ) { throw new TypeError('Expected TD or TH element'); diff --git a/lib/commons/text/title-text.js b/lib/commons/text/title-text.js index 0b40535348..ab9d7b6c32 100644 --- a/lib/commons/text/title-text.js +++ b/lib/commons/text/title-text.js @@ -1,15 +1,7 @@ import matches from '../matches/matches'; import getRole from '../aria/get-role'; -const alwaysTitleElements = [ - 'button', - 'iframe', - 'a[href]', - { - nodeName: 'input', - properties: { type: 'button' } - } -]; +const alwaysTitleElements = ['iframe']; /** * Get title text diff --git a/lib/core/base/audit.js b/lib/core/base/audit.js index 6eb1b8feff..a9719d369a 100644 --- a/lib/core/base/audit.js +++ b/lib/core/base/audit.js @@ -1,5 +1,6 @@ import Rule from './rule'; import Check from './check'; +import standards from '../../standards'; import RuleResult from './rule-result'; import { clone, @@ -152,6 +153,7 @@ class Audit { this.tagExclude = ['experimental']; this.lang = 'en'; this.defaultConfig = audit; + this.standards = standards; this._init(); // A copy of the "default" locale. This will be set if the user // provides a new locale to `axe.configure()` and used to undo diff --git a/lib/core/base/context.js b/lib/core/base/context.js index 21fb603d4a..ac8f3899a3 100644 --- a/lib/core/base/context.js +++ b/lib/core/base/context.js @@ -75,8 +75,11 @@ function pushUniqueFrameSelector(context, type, selectorArray) { */ function normalizeContext(context) { // typeof NodeList.length in PhantomJS === function - if ((context && typeof context === 'object') || context instanceof NodeList) { - if (context instanceof Node) { + if ( + (context && typeof context === 'object') || + context instanceof window.NodeList + ) { + if (context instanceof window.Node) { return { include: [context], exclude: [] @@ -140,7 +143,7 @@ function parseSelectorArray(context, type) { }) ); break; - } else if (item && item.length && !(item instanceof Node)) { + } else if (item && item.length && !(item instanceof window.Node)) { if (item.length > 1) { pushUniqueFrameSelector(context, type, item); } else { @@ -152,8 +155,8 @@ function parseSelectorArray(context, type) { }) ); } - } else if (item instanceof Node) { - if (item.documentElement instanceof Node) { + } else if (item instanceof window.Node) { + if (item.documentElement instanceof window.Node) { result.push(context.flatTree[0]); } else { result.push(getNodeFromTree(item)); @@ -198,11 +201,11 @@ function getRootNode({ include, exclude }) { for (var i = 0; i < selectors.length; ++i) { var item = selectors[i]; - if (item instanceof Element) { + if (item instanceof window.Element) { return item.ownerDocument.documentElement; } - if (item instanceof Document) { + if (item instanceof window.Document) { return item.documentElement; } } diff --git a/lib/core/public/configure.js b/lib/core/public/configure.js index 8022c9df6f..31f878fbbf 100644 --- a/lib/core/public/configure.js +++ b/lib/core/public/configure.js @@ -1,4 +1,5 @@ import { hasReporter } from './reporter'; +import { configureStandards } from '../../standards'; function configure(spec) { 'use strict'; @@ -106,6 +107,10 @@ function configure(spec) { if (spec.locale) { audit.applyLocale(spec.locale); } + + if (spec.standards) { + configureStandards(spec.standards); + } } export default configure; diff --git a/lib/core/public/reset.js b/lib/core/public/reset.js index dffe8cb762..57aef46b4c 100644 --- a/lib/core/public/reset.js +++ b/lib/core/public/reset.js @@ -1,4 +1,5 @@ -/*global axe */ +import { resetStandards } from '../../standards'; + function reset() { 'use strict'; var audit = axe._audit; @@ -7,6 +8,7 @@ function reset() { throw new Error('No audit configured'); } audit.resetRulesAndChecks(); + resetStandards(); } export default reset; diff --git a/lib/core/public/run.js b/lib/core/public/run.js index 5ea64fceb2..3560aa5cfa 100644 --- a/lib/core/public/run.js +++ b/lib/core/public/run.js @@ -5,8 +5,8 @@ function isContext(potential) { switch (true) { case typeof potential === 'string': case Array.isArray(potential): - case Node && potential instanceof Node: - case NodeList && potential instanceof NodeList: + case window.Node && potential instanceof window.Node: + case window.NodeList && potential instanceof window.NodeList: return true; case typeof potential !== 'object': @@ -87,6 +87,29 @@ function run(context, options, callback) { throw new Error('No audit configured'); } + // if window or document are not defined and context was passed in + // we can use it to configure them + // NOTE: because our polyfills run first, the global window object + // always exists but may not have things we expect + const hasWindow = window && 'Node' in window && 'NodeList' in window; + const hasDoc = !!document; + if (!hasWindow || !hasDoc) { + if (!context || !context.ownerDocument) { + throw new Error( + 'Required "window" or "document" globals not defined and cannot be deduced from the context. ' + + 'Either set the globals before running or pass in a valid Element.' + ); + } + + if (!hasDoc) { + document = context.ownerDocument; + } + + if (!hasWindow) { + window = document.defaultView; + } + } + let args = normalizeRunParams(context, options, callback); context = args.context; options = args.options; diff --git a/lib/core/utils/check-helper.js b/lib/core/utils/check-helper.js index 5353ea0b17..88e283d05c 100644 --- a/lib/core/utils/check-helper.js +++ b/lib/core/utils/check-helper.js @@ -25,7 +25,7 @@ function checkHelper(checkResult, options, resolve, reject) { checkResult.data = data; }, relatedNodes: function(nodes) { - nodes = nodes instanceof Node ? [nodes] : toArray(nodes); + nodes = nodes instanceof window.Node ? [nodes] : toArray(nodes); checkResult.relatedNodes = nodes.map(function(element) { return new DqElement(element, options); }); diff --git a/lib/standards/aria-attrs.js b/lib/standards/aria-attrs.js new file mode 100644 index 0000000000..0d9e6e0533 --- /dev/null +++ b/lib/standards/aria-attrs.js @@ -0,0 +1,200 @@ +// Source: https://www.w3.org/TR/wai-aria-1.1/#states_and_properties +const ariaAttrs = { + 'aria-activedescendant': { + type: 'idref', + allowEmpty: true + }, + 'aria-atomic': { + type: 'boolean', + global: true + }, + 'aria-autocomplete': { + type: 'nmtoken', + values: ['inline', 'list', 'both', 'none'] + }, + 'aria-busy': { + type: 'boolean', + global: true + }, + 'aria-checked': { + type: 'nmtoken', + values: ['false', 'mixed', 'true', 'undefined'] + }, + 'aria-colcount': { + type: 'int' + }, + 'aria-colindex': { + type: 'int' + }, + 'aria-colspan': { + type: 'int' + }, + 'aria-controls': { + type: 'idrefs', + allowEmpty: true, + global: true + }, + 'aria-current': { + type: 'nmtoken', + allowEmpty: true, + values: ['page', 'step', 'location', 'date', 'time', 'true', 'false'], + global: true + }, + 'aria-describedby': { + type: 'idrefs', + allowEmpty: true, + global: true + }, + 'aria-details': { + type: 'idref', + allowEmpty: true, + global: true + }, + 'aria-disabled': { + type: 'boolean', + global: true + }, + 'aria-dropeffect': { + type: 'nmtokens', + values: ['copy', 'execute', 'link', 'move', 'none', 'popup'], + global: true + }, + 'aria-errormessage': { + type: 'idref', + allowEmpty: true, + global: true + }, + 'aria-expanded': { + type: 'nmtoken', + values: ['true', 'false', 'undefined'] + }, + 'aria-flowto': { + type: 'idrefs', + allowEmpty: true, + global: true + }, + 'aria-grabbed': { + type: 'nmtoken', + values: ['true', 'false', 'undefined'], + global: true + }, + 'aria-haspopup': { + type: 'nmtoken', + allowEmpty: true, + values: ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'], + global: true + }, + 'aria-hidden': { + type: 'nmtoken', + values: ['true', 'false', 'undefined'], + global: true + }, + 'aria-invalid': { + type: 'nmtoken', + allowEmpty: true, + values: ['grammar', 'false', 'spelling', 'true'], + global: true + }, + 'aria-keyshortcuts': { + type: 'string', + allowEmpty: true, + global: true + }, + 'aria-label': { + type: 'string', + allowEmpty: true, + global: true + }, + 'aria-labelledby': { + type: 'idrefs', + allowEmpty: true, + global: true + }, + 'aria-level': { + type: 'int' + }, + 'aria-live': { + type: 'nmtoken', + values: ['assertive', 'off', 'polite'], + global: true + }, + 'aria-modal': { + type: 'boolean' + }, + 'aria-multiline': { + type: 'boolean' + }, + 'aria-multiselectable': { + type: 'boolean' + }, + 'aria-orientation': { + type: 'nmtoken', + values: ['horizontal', 'undefined', 'vertical'] + }, + 'aria-owns': { + type: 'idrefs', + allowEmpty: true, + global: true + }, + 'aria-placeholder': { + type: 'string', + allowEmpty: true + }, + 'aria-posinset': { + type: 'int' + }, + 'aria-pressed': { + type: 'nmtoken', + values: ['false', 'mixed', 'true', 'undefined'] + }, + 'aria-readonly': { + type: 'boolean' + }, + 'aria-relevant': { + type: 'nmtokens', + values: ['additions', 'all', 'removals', 'text'], + global: true + }, + 'aria-required': { + type: 'boolean' + }, + 'aria-roledescription': { + type: 'string', + allowEmpty: true, + global: true + }, + 'aria-rowcount': { + type: 'int' + }, + 'aria-rowindex': { + type: 'int' + }, + 'aria-rowspan': { + type: 'int' + }, + 'aria-selected': { + type: 'nmtoken', + values: ['false', 'true', 'undefined'] + }, + 'aria-setsize': { + type: 'int' + }, + 'aria-sort': { + type: 'nmtoken', + values: ['ascending', 'descending', 'none', 'other'] + }, + 'aria-valuemax': { + type: 'decimal' + }, + 'aria-valuemin': { + type: 'decimal' + }, + 'aria-valuenow': { + type: 'decimal' + }, + 'aria-valuetext': { + type: 'string' + } +}; + +export default ariaAttrs; diff --git a/lib/standards/aria-roles.js b/lib/standards/aria-roles.js new file mode 100644 index 0000000000..492124e3c7 --- /dev/null +++ b/lib/standards/aria-roles.js @@ -0,0 +1,609 @@ +// Source: https://www.w3.org/TR/wai-aria-1.1/#roles + +/* easiest way to see allowed roles is to filter out the global ones + from the list of inherited states and properties. The dpub spec + does not have the global list so you'll need to copy over from + the wai-aria one: + + const globalAttrs = Array.from( + document.querySelectorAll('#global_states li') + ).map(li => li.textContent.replace(/\s*\(.*\)/, '')); + + const globalRoleAttrs = Array.from( + document.querySelectorAll('.role-inherited li') + ).filter(li => globalAttrs.includes(li.textContent.replace(/\s*\(.*\)/, ''))) + + globalRoleAttrs.forEach(li => li.style.display = 'none'); +*/ +const ariaRoles = { + alert: { + type: 'widget', + allowedAttrs: ['aria-expanded'] + }, + alertdialog: { + type: 'widget', + allowedAttrs: ['aria-expanded', 'aria-modal'] + }, + application: { + // Note: spec difference + type: 'landmark', + // Note: aria-expanded is not in the 1.1 spec but is + // consistently supported in ATs and was added in 1.2 + allowedAttrs: ['aria-activedescendant', 'aria-expanded'] + }, + article: { + type: 'structure', + allowedAttrs: ['aria-posinset', 'aria-setsize', 'aria-expanded'] + }, + banner: { + type: 'landmark', + allowedAttrs: ['aria-expanded'] + }, + button: { + type: 'widget', + allowedAttrs: ['aria-expanded', 'aria-pressed'], + nameFromContent: true + }, + cell: { + type: 'structure', + requiredContext: ['row'], + allowedAttrs: [ + 'aria-colindex', + 'aria-colspan', + 'aria-rowindex', + 'aria-rowspan', + 'aria-expanded' + ], + nameFromContent: true + }, + checkbox: { + type: 'widget', + // Note: since the checkbox role has an implicit + // aria-checked value it is not required to be added by + // the user + // + // Note: aria-required is not in the 1.1 spec but is + // consistently supported in ATs and was added in 1.2 + allowedAttrs: ['aria-checked', 'aria-readonly', 'aria-required'], + nameFromContent: true + }, + columnheader: { + type: 'structure', + requiredContext: ['row'], + allowedAttrs: [ + 'aria-sort', + 'aria-colindex', + 'aria-colspan', + 'aria-expanded', + 'aria-readonly', + 'aria-required', + 'aria-rowindex', + 'aria-rowspan', + 'aria-selected' + ], + nameFromContent: true + }, + combobox: { + type: 'composite', + requiredOwned: ['textbox', 'listbox', 'tree', 'grid', 'dialog'], + requiredAttrs: ['aria-expanded'], + // Note: because aria-controls is not well supported we will not + // make it a required attribute even though it is required in the + // spec + allowedAttrs: [ + 'aria-controls', + 'aria-autocomplete', + 'aria-readonly', + 'aria-required', + 'aria-activedescendant', + 'aria-orientation' + ] + }, + command: { + type: 'abstract' + }, + complementary: { + type: 'landmark', + allowedAttrs: ['aria-expanded'] + }, + composite: { + type: 'abstract' + }, + contentinfo: { + type: 'landmark', + allowedAttrs: ['aria-expanded'] + }, + definition: { + type: 'structure', + allowedAttrs: ['aria-expanded'] + }, + dialog: { + type: 'widget', + allowedAttrs: ['aria-expanded', 'aria-modal'] + }, + directory: { + type: 'structure', + allowedAttrs: ['aria-expanded'], + // Note: spec difference + nameFromContent: true + }, + document: { + type: 'structure', + allowedAttrs: ['aria-expanded'] + }, + feed: { + type: 'structure', + requiredOwned: ['article'], + allowedAttrs: ['aria-expanded'] + }, + figure: { + type: 'structure', + allowedAttrs: ['aria-expanded'], + // Note: spec difference + nameFromContent: true + }, + form: { + type: 'landmark', + allowedAttrs: ['aria-expanded'] + }, + grid: { + type: 'composite', + requiredOwned: ['rowgroup', 'row'], + allowedAttrs: [ + 'aria-level', + 'aria-multiselectable', + 'aria-readonly', + 'aria-activedescendant', + 'aria-colcount', + 'aria-expanded', + 'aria-rowcount' + ] + }, + gridcell: { + type: 'widget', + requiredContext: ['row'], + allowedAttrs: [ + 'aria-readonly', + 'aria-required', + 'aria-selected', + 'aria-colindex', + 'aria-colspan', + 'aria-expanded', + 'aria-rowindex', + 'aria-rowspan' + ], + nameFromContent: true + }, + group: { + type: 'structure', + allowedAttrs: ['aria-activedescendant', 'aria-expanded'] + }, + heading: { + type: 'structure', + requiredAttrs: ['aria-level'], + allowedAttrs: ['aria-expanded'], + nameFromContent: true + }, + img: { + type: 'structure', + allowedAttrs: ['aria-expanded'] + }, + input: { + type: 'abstract' + }, + landmark: { + type: 'abstract' + }, + link: { + type: 'widget', + allowedAttrs: ['aria-expanded'], + nameFromContent: true + }, + list: { + type: 'structure', + requiredOwned: ['listitem'], + allowedAttrs: ['aria-expanded'] + }, + listbox: { + type: 'composite', + requiredOwned: ['option'], + allowedAttrs: [ + 'aria-multiselectable', + 'aria-readonly', + 'aria-required', + 'aria-activedescendant', + 'aria-expanded', + 'aria-orientation' + ] + }, + listitem: { + type: 'structure', + requiredContext: ['list'], + allowedAttrs: [ + 'aria-level', + 'aria-posinset', + 'aria-setsize', + 'aria-expanded' + ], + // Note: spec difference + nameFromContent: true + }, + log: { + type: 'widget', + allowedAttrs: ['aria-expanded'] + }, + main: { + type: 'landmark', + allowedAttrs: ['aria-expanded'] + }, + marquee: { + type: 'widget', + allowedAttrs: ['aria-expanded'] + }, + math: { + type: 'structure', + allowedAttrs: ['aria-expanded'] + }, + menu: { + type: 'composite', + requiredOwned: ['menuitemradio', 'menuitem', 'menuitemcheckbox'], + allowedAttrs: ['aria-activedescendant', 'aria-expanded', 'aria-orientation'] + }, + menubar: { + type: 'composite', + requiredOwned: ['menuitemradio', 'menuitem', 'menuitemcheckbox'], + allowedAttrs: ['aria-activedescendant', 'aria-expanded', 'aria-orientation'] + }, + menuitem: { + type: 'widget', + requiredContext: ['menu', 'menubar'], + // Note: aria-expanded is not in the 1.1 spec but is + // consistently supported in ATs and was added in 1.2 + allowedAttrs: ['aria-posinset', 'aria-setsize', 'aria-expanded'], + nameFromContent: true + }, + menuitemcheckbox: { + type: 'widget', + requiredContext: ['menu', 'menubar'], + allowedAttrs: [ + 'aria-checked', + 'aria-posinset', + 'aria-readonly', + 'aria-setsize' + ], + nameFromContent: true + }, + menuitemradio: { + type: 'widget', + requiredContext: ['menu', 'menubar'], + allowedAttrs: [ + 'aria-checked', + 'aria-posinset', + 'aria-readonly', + 'aria-setsize' + ], + nameFromContent: true + }, + navigation: { + type: 'landmark', + allowedAttrs: ['aria-expanded'] + }, + note: { + type: 'structure', + allowedAttrs: ['aria-expanded'] + }, + option: { + type: 'widget', + // Note: since the option role has an implicit + // aria-selected value it is not required to be added by + // the user + allowedAttrs: [ + 'aria-selected', + 'aria-checked', + 'aria-posinset', + 'aria-setsize' + ], + nameFromContent: true + }, + presentation: { + type: 'structure' + }, + progressbar: { + type: 'widget', + allowedAttrs: [ + 'aria-expanded', + 'aria-valuemax', + 'aria-valuemin', + 'aria-valuenow', + 'aria-valuetext' + ] + }, + radio: { + type: 'widget', + // Note: since the radio role has an implicit + // aria-check value it is not required to be added by + // the user + // + // Note: aria-required is not in the 1.1 or 1.2 specs but is + // consistently supported in ATs on the individual radio element + allowedAttrs: [ + 'aria-checked', + 'aria-posinset', + 'aria-setsize', + 'aria-required' + ], + nameFromContent: true + }, + radiogroup: { + type: 'composite', + requiredOwned: ['radio'], + allowedAttrs: [ + 'aria-readonly', + 'aria-required', + 'aria-activedescendant', + 'aria-expanded', + 'aria-orientation' + ] + }, + range: { + type: 'abstract' + }, + region: { + type: 'landmark', + allowedAttrs: ['aria-expanded'] + }, + roletype: { + type: 'abstract' + }, + row: { + type: 'structure', + requiredContext: ['grid', 'rowgroup', 'table', 'treegrid'], + requiredOwned: ['cell', 'columnheader', 'gridcell', 'rowheader'], + allowedAttrs: [ + 'aria-colindex', + 'aria-level', + 'aria-rowindex', + 'aria-selected', + 'aria-activedescendant', + 'aria-expanded' + ], + nameFromContent: true + }, + rowgroup: { + type: 'structure', + requiredContext: ['grid', 'table', 'treegrid'], + requiredOwned: ['row'], + nameFromContent: true + }, + rowheader: { + type: 'structure', + requiredContext: ['row'], + allowedAttrs: [ + 'aria-sort', + 'aria-colindex', + 'aria-colspan', + 'aria-expanded', + 'aria-readonly', + 'aria-required', + 'aria-rowindex', + 'aria-rowspan', + 'aria-selected' + ], + nameFromContent: true + }, + scrollbar: { + type: 'widget', + // Note: since the scrollbar role has implicit + // aria-orientation, aria-valuemax, aria-valuemin, and + // aria-valuenow values it is not required to be added by + // the user + // + // Note: because aria-controls is not well supported we will not + // make it a required attribute even though it is required in the + // spec + allowedAttrs: [ + 'aria-controls', + 'aria-orientation', + 'aria-valuemax', + 'aria-valuemin', + 'aria-valuenow', + 'aria-valuetext' + ] + }, + search: { + type: 'landmark', + allowedAttrs: ['aria-expanded'] + }, + searchbox: { + type: 'widget', + allowedAttrs: [ + 'aria-activedescendant', + 'aria-autocomplete', + 'aria-multiline', + 'aria-placeholder', + 'aria-readonly', + 'aria-required' + ] + }, + section: { + type: 'abstract', + // Note: spec difference + nameFromContent: true + }, + sectionhead: { + type: 'abstract', + // Note: spec difference + nameFromContent: true + }, + select: { + type: 'abstract' + }, + separator: { + type: 'structure', + // Note: since the separator role has implicit + // aria-orientation, aria-valuemax, aria-valuemin, and + // aria-valuenow values it is not required to be added by + // the user + allowedAttrs: [ + 'aria-valuemax', + 'aria-valuemin', + 'aria-valuenow', + 'aria-orientation', + 'aria-valuetext' + ] + }, + slider: { + type: 'widget', + // Note: since the slider role has implicit + // aria-orientation, aria-valuemax, aria-valuemin, and + // aria-valuenow values it is not required to be added by + // the user + allowedAttrs: [ + 'aria-valuemax', + 'aria-valuemin', + 'aria-valuenow', + 'aria-orientation', + 'aria-readonly', + 'aria-valuetext' + ] + }, + spinbutton: { + type: 'widget', + // Note: since the spinbutton role has implicit + // aria-orientation, aria-valuemax, aria-valuemin, and + // aria-valuenow values it is not required to be added by + // the user + allowedAttrs: [ + 'aria-valuemax', + 'aria-valuemin', + 'aria-valuenow', + 'aria-readonly', + 'aria-required', + 'aria-activedescendant', + 'aria-valuetext' + ] + }, + status: { + type: 'widget', + allowedAttrs: ['aria-expanded'] + }, + structure: { + type: 'abstract' + }, + switch: { + type: 'widget', + requiredAttrs: ['aria-checked'], + allowedAttrs: ['aria-readonly'], + nameFromContent: true + }, + tab: { + type: 'widget', + requiredContext: ['tablist'], + allowedAttrs: [ + 'aria-posinset', + 'aria-selected', + 'aria-setsize', + 'aria-expanded' + ], + nameFromContent: true + }, + table: { + type: 'structure', + requiredOwned: ['rowgroup', 'row'], + allowedAttrs: ['aria-colcount', 'aria-rowcount', 'aria-expanded'] + }, + tablist: { + type: 'composite', + requiredOwned: ['tab'], + // NOTE: aria-expanded is from the 1.0 spec but is still + // consistently supported in ATs + allowedAttrs: [ + 'aria-level', + 'aria-multiselectable', + 'aria-orientation', + 'aria-activedescendant', + 'aria-expanded' + ] + }, + tabpanel: { + type: 'widget', + allowedAttrs: ['aria-expanded'] + }, + term: { + type: 'structure', + allowedAttrs: ['aria-expanded'], + // Note: spec difference + nameFromContent: true + }, + textbox: { + type: 'widget', + allowedAttrs: [ + 'aria-activedescendant', + 'aria-autocomplete', + 'aria-multiline', + 'aria-placeholder', + 'aria-readonly', + 'aria-required' + ] + }, + timer: { + type: 'widget', + allowedAttrs: ['aria-expanded'] + }, + toolbar: { + type: 'structure', + allowedAttrs: ['aria-orientation', 'aria-activedescendant', 'aria-expanded'] + }, + tooltip: { + type: 'structure', + allowedAttrs: ['aria-expanded'], + nameFromContent: true + }, + tree: { + type: 'composite', + requiredOwned: ['treeitem'], + allowedAttrs: [ + 'aria-multiselectable', + 'aria-required', + 'aria-activedescendant', + 'aria-expanded', + 'aria-orientation' + ] + }, + treegrid: { + type: 'composite', + requiredOwned: ['rowgroup', 'row'], + allowedAttrs: [ + 'aria-activedescendant', + 'aria-colcount', + 'aria-expanded', + 'aria-level', + 'aria-multiselectable', + 'aria-orientation', + 'aria-readonly', + 'aria-required', + 'aria-rowcount' + ] + }, + treeitem: { + type: 'widget', + requiredContext: ['group', 'tree'], + allowedAttrs: [ + 'aria-checked', + 'aria-expanded', + 'aria-level', + 'aria-posinset', + 'aria-selected', + 'aria-setsize' + ], + nameFromContent: true + }, + widget: { + type: 'abstract' + }, + window: { + type: 'abstract' + } +}; + +export default ariaRoles; diff --git a/lib/standards/html-aria.js b/lib/standards/html-aria.js new file mode 100644 index 0000000000..85b3d1e91c --- /dev/null +++ b/lib/standards/html-aria.js @@ -0,0 +1,715 @@ +// Source: https://www.w3.org/TR/html-aria/#allowed-aria-roles-states-and-properties +// Source: https://www.w3.org/TR/html-aam-1.0/#html-element-role-mappings +const htmlElms = { + a: { + variant: { + href: { + matches: '[href]', + allowedRoles: [ + 'button', + 'checkbox', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'radio', + 'switch', + 'tab', + 'treeitem', + 'doc-backlink', + 'doc-biblioref', + 'doc-glossref', + 'doc-noteref' + ] + }, + noHref: { + matches: { + attributes: { + href: null + } + }, + allowedRoles: true + } + } + }, + abbr: { + allowedRoles: true + }, + addres: { + allowedRoles: true + }, + area: { + allowedRoles: false + }, + article: { + allowedRoles: [ + 'feed', + 'presentation', + 'none', + 'document', + 'application', + 'main', + 'region' + ] + }, + aside: { + allowedRoles: [ + 'feed', + 'note', + 'presentation', + 'none', + 'region', + 'search', + 'doc-dedication', + 'doc-example', + 'doc-footnote', + 'doc-pullquote', + 'doc-tip' + ] + }, + audio: { + variant: { + controls: { + matches: '[controls]', + allowedRoles: ['application'] + }, + noControls: { + allowedRoles: ['application'] + } + } + }, + b: { + allowedRoles: false + }, + base: { + allowedRoles: false, + noAriaAttrs: true + }, + bdi: { + allowedRoles: true + }, + bdo: { + allowedRoles: true + }, + blockquote: { + allowedRoles: true + }, + body: { + allowedRoles: false + }, + br: { + allowedRoles: ['presentation', 'none'] + }, + button: { + allowedRoles: [ + 'checkbox', + 'link', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'radio', + 'switch', + 'tab' + ] + }, + canvas: { + allowedRoles: true + }, + caption: { + allowedRoles: false + }, + cite: { + allowedRoles: true + }, + code: { + allowedRoles: true + }, + col: { + allowedRoles: false, + noAriaAttrs: true + }, + colgroup: { + allowedRoles: false, + noAriaAttrs: true + }, + data: { + allowedRoles: true + }, + datalist: { + allowedRoles: false, + implicitAttrs: { + // Note: even though the value of aria-multiselectable is based + // on the attributes, we don't currently need to know the + // precise value. however, this allows us to make the attribute + // future proof in case we ever do need to know it + 'aria-multiselectable': 'false' + } + }, + dd: { + allowedRoles: false + }, + del: { + allowedRoles: true + }, + dfn: { + allowedRoles: true + }, + details: { + allowedRoles: false + }, + dialog: { + allowedRoles: ['alertdialog'] + }, + div: { + allowedRoles: true + }, + dl: { + allowedRoles: ['group', 'list', 'presentation', 'none'] + }, + dt: { + allowedRoles: ['listitem'] + }, + em: { + allowedRoles: true + }, + embed: { + allowedRoles: ['application', 'document', 'img', 'presentation', 'none'] + }, + fieldset: { + allowedRoles: ['none', 'presentation', 'radiogroup'] + }, + figcaption: { + allowedRoles: ['group', 'none', 'presentation'] + }, + figure: { + // Note: technically you're allowed no role when a figcaption + // descendant, but we can't match that so we'll go with any role + allowedRoles: true + }, + footer: { + allowedRoles: ['group', 'none', 'presentation', 'doc-footnote'] + }, + form: { + allowedRoles: ['search', 'none', 'presentation'] + }, + h1: { + allowedRoles: ['none', 'presentation', 'tab', 'doc-subtitle'], + implicitAttrs: { + 'aria-level': '1' + } + }, + h2: { + allowedRoles: ['none', 'presentation', 'tab', 'doc-subtitle'], + implicitAttrs: { + 'aria-level': '2' + } + }, + h3: { + allowedRoles: ['none', 'presentation', 'tab', 'doc-subtitle'], + implicitAttrs: { + 'aria-level': '3' + } + }, + h4: { + allowedRoles: ['none', 'presentation', 'tab', 'doc-subtitle'], + implicitAttrs: { + 'aria-level': '4' + } + }, + h5: { + allowedRoles: ['none', 'presentation', 'tab', 'doc-subtitle'], + implicitAttrs: { + 'aria-level': '5' + } + }, + h6: { + allowedRoles: ['none', 'presentation', 'tab', 'doc-subtitle'], + implicitAttrs: { + 'aria-level': '6' + } + }, + head: { + allowedRoles: false, + noAriaAttrs: true + }, + header: { + allowedRoles: ['group', 'none', 'presentation', 'doc-footnote'] + }, + hgroup: { + allowedRoles: true + }, + hr: { + allowedRoles: ['none', 'presentation', 'doc-pagebreak'] + }, + html: { + allowedRoles: false, + noAriaAttrs: true + }, + i: { + allowedRoles: true + }, + iframe: { + allowedRoles: ['application', 'document', 'img', 'none', 'presentation'] + }, + img: { + variant: { + alt: { + matches: { + attributes: { + alt: '/.+/' + } + }, + allowedRoles: [ + 'button', + 'checkbox', + 'link', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'progressbar', + 'scrollbar', + 'separator', + 'slider', + 'switch', + 'tab', + 'treeitem', + 'doc-cover' + ] + }, + emptyAlt: { + matches: '[alt]', + allowedRoles: ['presentation', 'none'] + }, + noAlt: { + matches: { + attributes: { + alt: null + } + }, + // Note: if img has accessible name then uses alt="some text" + // but we can't match that so we'll go with false + allowedRoles: false + } + } + }, + input: { + variant: { + button: { + matches: '[type="button"]', + allowedRoles: [ + 'link', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'radio', + 'switch', + 'tab' + ] + }, + checkboxPressed: { + matches: '[type="checkbox"][aria-pressed]', + allowedRoles: ['button', 'menuitemcheckbox', 'option', 'switch'], + implicitAttrs: { + 'aria-checked': 'false' + } + }, + checkbox: { + matches: '[type="checkbox"]', + allowedRoles: ['menuitemcheckbox', 'option', 'switch'], + implicitAttrs: { + 'aria-checked': 'false' + } + }, + noRoles: { + matches: { + attributes: { + type: [ + 'color', + 'date', + 'datetime-local', + 'email', + 'file', + 'month', + 'number', + 'password', + 'range', + 'reset', + 'search', + 'submit', + 'tel', + 'time', + 'url', + 'week' + ] + } + }, + allowedRoles: false + }, + hidden: { + matches: '[type="hidden"]', + allowedRoles: false, + noAriaAttrs: true + }, + image: { + matches: { + attributes: '[type="image"]' + }, + allowedRoles: [ + 'link', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'radio', + 'switch' + ] + }, + radio: { + matches: '[type="radio"]', + allowedRoles: ['menuitemradio'], + implicitAttrs: { + 'aria-checked': 'false' + } + }, + textWithList: { + matches: '[type="text"][list]', + allowedRoles: false + }, + // Note: this covers type=text and any other input type + default: { + matches: { + attributes: { + list: null + } + }, + allowedRoles: ['combobox', 'searchbox', 'spinbutton'] + } + } + }, + ins: { + allowedRoles: true + }, + kdb: { + allowedRoles: true + }, + label: { + allowedRoles: false + }, + legend: { + allowedRoles: false + }, + li: { + allowedRoles: [ + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'none', + 'presentation', + 'radio', + 'separator', + 'tab', + 'treeitem', + 'doc-biblioentry', + 'doc-endnote' + ], + implicitAttrs: { + 'aria-setsize': '1', + 'aria-posinset': '1' + } + }, + link: { + allowedRoles: false, + noAriaAttrs: true + }, + main: { + allowedRoles: false + }, + map: { + allowedRoles: false, + noAriaAttrs: true + }, + math: { + allowedRoles: false + }, + mark: { + allowedRoles: true + }, + menu: { + allowedRoles: [ + 'directory', + 'group', + 'listbox', + 'menu', + 'menubar', + 'none', + 'presentation', + 'radiogroup', + 'tablist', + 'toolbar', + 'tree' + ] + }, + meta: { + allowedRoles: false, + noAriaAttrs: true + }, + meter: { + allowedRoles: false + }, + nav: { + allowedRoles: ['doc-index', 'doc-pagelist', 'doc-toc'] + }, + noscript: { + allowedRoles: false, + noAriaAttrs: true + }, + object: { + allowedRoles: ['application', 'document', 'img'] + }, + ol: { + allowedRoles: [ + 'directory', + 'group', + 'listbox', + 'menu', + 'menubar', + 'none', + 'presentation', + 'radiogroup', + 'tablist', + 'toolbar', + 'tree' + ] + }, + optgroup: { + allowedRoles: false + }, + option: { + allowedRoles: false, + implicitAttrs: { + 'aria-selected': 'false' + } + }, + output: { + allowedRoles: true + }, + p: { + allowedRoles: true + }, + param: { + allowedRoles: false, + noAriaAttrs: true + }, + picture: { + allowedRoles: false, + noAriaAttrs: true + }, + pre: { + allowedRoles: true + }, + progress: { + allowedRoles: true, + implicitAttrs: { + 'aria-valuemax': '100', + 'aria-valuemin': '0', + 'aria-valuenow': '0' + } + }, + q: { + allowedRoles: true + }, + rp: { + allowedRoles: true + }, + rt: { + allowedRoles: true + }, + ruby: { + allowedRoles: true + }, + s: { + allowedRoles: true + }, + samp: { + allowedRoles: true + }, + script: { + allowedRoles: false, + noAriaAttrs: true + }, + section: { + allowedRoles: [ + 'alert', + 'alertdialog', + 'application', + 'banner', + 'complementary', + 'contentinfo', + 'dialog', + 'document', + 'feed', + 'log', + 'main', + 'marquee', + 'navigation', + 'none', + 'note', + 'presentation', + 'search', + 'status', + 'tabpanel', + 'doc-abstract', + 'doc-acknowledgments', + 'doc-afterword', + 'doc-appendix', + 'doc-bibliography', + 'doc-chapter', + 'doc-colophon', + 'doc-conclusion', + 'doc-credit', + 'doc-credits', + 'doc-dedication', + 'doc-endnotes', + 'doc-epigraph', + 'doc-epilogue', + 'doc-errata', + 'doc-example', + 'doc-foreword', + 'doc-glossary', + 'doc-index', + 'doc-introduction', + 'doc-notice', + 'doc-pagelist', + 'doc-part', + 'doc-preface', + 'doc-prologue', + 'doc-pullquote', + 'doc-qna', + 'doc-toc' + ] + }, + select: { + variant: { + combobox: { + matches: { + attributes: { + multiple: null, + size: [null, '1'] + } + }, + allowedRoles: ['menu'] + }, + listbox: { + matches: '[multiple], select[size]:not([size="1"])', + allowedRoles: false + } + } + }, + slot: { + allowedRoles: false, + noAriaAttrs: true + }, + small: { + allowedRoles: true + }, + source: { + allowedRoles: false, + noAriaAttrs: true + }, + span: { + allowedRoles: true + }, + strong: { + allowedRoles: true + }, + style: { + allowedRoles: false, + noAriaAttrs: true + }, + svg: { + allowedRoles: ['application', 'document', 'img'] + }, + sub: { + allowedRoles: true + }, + summary: { + allowedRoles: false + }, + sup: { + allowedRoles: true + }, + table: { + allowedRoles: true + }, + tbody: { + allowedRoles: true + }, + template: { + allowedRoles: false, + noAriaAttrs: true + }, + textarea: { + allowedRoles: false, + implicitAttrs: { + 'aria-multiline': 'true' + } + }, + tfoot: { + allowedRoles: true + }, + thead: { + allowedRoles: true + }, + time: { + allowedRoles: true + }, + title: { + allowedRoles: false, + noAriaAttrs: true + }, + td: { + allowedRoles: true + }, + th: { + allowedRoles: true + }, + tr: { + allowedRoles: true + }, + track: { + allowedRoles: false, + noAriaAttrs: true + }, + u: { + allowedRoles: true + }, + ul: { + allowedRoles: [ + 'directory', + 'group', + 'listbox', + 'menu', + 'menubar', + 'none', + 'presentation', + 'radiogroup', + 'tablist', + 'toolbar', + 'tree' + ] + }, + var: { + allowedRoles: true + }, + video: { + allowedRoles: ['application'] + }, + wbr: { + allowedRoles: true + } +}; + +export default htmlElms; diff --git a/lib/standards/index.js b/lib/standards/index.js new file mode 100644 index 0000000000..9796e4d601 --- /dev/null +++ b/lib/standards/index.js @@ -0,0 +1,31 @@ +import ariaAttrs from './aria-attrs'; +import { clone } from '../core/utils'; + +const origAriaAttrs = clone(ariaAttrs); +const standards = { + ariaAttrs +}; + +// @see https://stackoverflow.com/a/59008477/2124254 +function merge(current, updates) { + for (const key of Object.keys(updates)) { + if (!current.hasOwnProperty(key) || typeof updates[key] !== 'object' || Array.isArray(current[key])) { + current[key] = updates[key]; + } else { + merge(current[key], updates[key]); + } + } + return current; +} + +export function configureStandards(config) { + if (config.ariaAttrs) { + standards.ariaAttrs = merge(ariaAttrs, config.ariaAttrs); + } +} + +export function resetStandards() { + standards.ariaAttrs = origAriaAttrs; +} + +export default standards; diff --git a/test/checks/color/color-contrast.js b/test/checks/color/color-contrast.js index facf17fb94..9afe1f2467 100644 --- a/test/checks/color/color-contrast.js +++ b/test/checks/color/color-contrast.js @@ -148,6 +148,14 @@ describe('color-contrast', function() { assert.deepEqual(checkContext._relatedNodes, []); }); + it('should return true for truncated inline elements', function() { + var params = checkSetup( + '

Text oh heyyyy Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed et sollicitudin quam. Fusce mi odio, egestas pulvinar erat eget, vehicula tempus est. Proin vitae ullamcorper velit. Donec sagittis est justo, mattis iaculis arcu facilisis id. Proin pulvinar ornare arcu a fermentum. Quisque et dignissim nulla, sit amet consectetur ipsum. Donec in libero porttitor, dapibus neque imperdiet, aliquam est. Vivamus blandit volutpat fringilla. In mi magna, mollis sit amet imperdiet eu, rutrum ut tellus. Mauris vel condimentum nibh, quis ultricies nisi. Vivamus accumsan quam mauris, id iaculis quam fringilla ac. Curabitur pulvinar dolor ac magna vehicula, non auctor ligula dignissim. Nam ac nibh porttitor, malesuada tortor varius, feugiat turpis. Mauris dapibus, tellus ut viverra porta, ipsum turpis bibendum ligula, at tempor felis ante non libero. Donec dapibus, diam sit amet posuere commodo, magna orci hendrerit ipsum, eu egestas mauris nulla ut ipsum. Sed luctus, orci in fringilla finibus, odio leo porta dolor, eu dignissim risus eros eget erat

' + ); + assert.isTrue(contrastEvaluate.apply(checkContext, params)); + assert.deepEqual(checkContext._relatedNodes, []); + }); + it('should return true for inline elements with sufficient contrast', function() { var params = checkSetup( '

Text oh heyyyy and here\'s bold text

' diff --git a/test/commons/aria/attributes.js b/test/commons/aria/attributes.js index bbcba380d7..191178eb34 100644 --- a/test/commons/aria/attributes.js +++ b/test/commons/aria/attributes.js @@ -33,16 +33,19 @@ describe('aria.allowedAttr', function() { 'use strict'; var orig; + var origGlobals; beforeEach(function() { orig = axe.commons.aria.lookupTable.role; + origGlobals = axe.commons.aria.lookupTable.globalAttributes; }); afterEach(function() { axe.commons.aria.lookupTable.role = orig; + axe.commons.aria.lookupTable.globalAttributes = origGlobals; }); it('should returned the attributes property for the proper role', function() { - var orig = (axe.commons.aria.lookupTable.globalAttributes = ['world']); + axe.commons.aria.lookupTable.globalAttributes = ['world']; axe.commons.aria.lookupTable.role = { cats: { attributes: { @@ -52,11 +55,10 @@ describe('aria.allowedAttr', function() { }; assert.deepEqual(axe.commons.aria.allowedAttr('cats'), ['hello', 'world']); - axe.commons.aria.lookupTable.globalAttributes = orig; }); it('should also check required attributes', function() { - var orig = (axe.commons.aria.lookupTable.globalAttributes = ['world']); + axe.commons.aria.lookupTable.globalAttributes = ['world']; axe.commons.aria.lookupTable.role = { cats: { attributes: { @@ -71,18 +73,13 @@ describe('aria.allowedAttr', function() { 'world', 'hello' ]); - axe.commons.aria.lookupTable.globalAttributes = orig; }); it('should return an array with globally allowed attributes', function() { - var result, - orig = (axe.commons.aria.lookupTable.globalAttributes = ['world']); - + axe.commons.aria.lookupTable.globalAttributes = ['world']; axe.commons.aria.lookupTable.role = {}; - result = axe.commons.aria.allowedAttr('cats'); - assert.deepEqual(result, ['world']); - axe.commons.aria.lookupTable.globalAttributes = orig; + assert.deepEqual(axe.commons.aria.allowedAttr('cats'), ['world']); }); }); diff --git a/test/commons/aria/get-role.js b/test/commons/aria/get-role.js index 9d59679dcf..317520babd 100644 --- a/test/commons/aria/get-role.js +++ b/test/commons/aria/get-role.js @@ -3,6 +3,7 @@ describe('aria.getRole', function() { var aria = axe.commons.aria; var roleDefinitions = aria.lookupTable.role; var flatTreeSetup = axe.testUtils.flatTreeSetup; + var fixture = document.querySelector('#fixture'); var orig; beforeEach(function() { @@ -10,6 +11,7 @@ describe('aria.getRole', function() { }); afterEach(function() { + fixture.innerHTML = ''; axe.commons.aria.lookupTable.role = orig; }); @@ -48,9 +50,9 @@ describe('aria.getRole', function() { }); it('returns the implicit role if the explicit is invalid', function() { - var node = document.createElement('li'); - node.setAttribute('role', 'foobar'); - flatTreeSetup(node); + fixture.innerHTML = ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); assert.equal(aria.getRole(node), 'listitem'); }); @@ -74,10 +76,182 @@ describe('aria.getRole', function() { assert.isNull(aria.getRole(node)); }); + it('runs role resolution with role=none', function() { + fixture.innerHTML = + ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'listitem'); + }); + + it('runs role resolution with role=presentation', function() { + fixture.innerHTML = + ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'listitem'); + }); + + it('handles focusable element with role="none"', function() { + var node = document.createElement('button'); + node.setAttribute('role', 'none'); + flatTreeSetup(node); + assert.equal(aria.getRole(node), 'button'); + }); + + describe('presentational role inheritance', function() { + it('handles presentation role inheritance for ul', function() { + fixture.innerHTML = + ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for ol', function() { + fixture.innerHTML = + '
  1. foo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for dt', function() { + fixture.innerHTML = + '
foo
bar>
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for dd', function() { + fixture.innerHTML = + '
foo
bar>
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for dt with div wrapper', function() { + fixture.innerHTML = + '
foo
bar>
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for dd with div wrapper', function() { + fixture.innerHTML = + '
foo
bar>
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for thead', function() { + fixture.innerHTML = + '
higoodbye
hifoo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for td', function() { + fixture.innerHTML = + '
higoodbye
hifoo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for th', function() { + fixture.innerHTML = + '
higoodbye
hifoo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for tbody', function() { + fixture.innerHTML = + '
higoodbye
hifoo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for tr', function() { + fixture.innerHTML = + '
higoodbye
hifoo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('handles presentation role inheritance for tfoot', function() { + fixture.innerHTML = + '
higoodbye
hifoo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('returns implicit role for presentation role inheritance if ancestor is not the required ancestor', function() { + fixture.innerHTML = + '
  • foo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'listitem'); + }); + + it('does not override explicit role with presentation role inheritance', function() { + fixture.innerHTML = + ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'listitem'); + }); + + it('does not continue presentation role with explicit role in between', function() { + fixture.innerHTML = + '
foo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'cell'); + }); + + it('handles presentation role inheritance with invalid role in between', function() { + fixture.innerHTML = + '
foo
'; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'presentation'); + }); + + it('does not continue presentation role through nested layers', function() { + fixture.innerHTML = + ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); + assert.equal(aria.getRole(node), 'listitem'); + }); + + it('throws an error if the tree is incomplete', function() { + fixture.innerHTML = + ''; + var node = fixture.querySelector('#target'); + flatTreeSetup(node); + assert.throws(function() { + aria.getRole(node); + }); + }); + }); + describe('noImplicit', function() { it('returns the implicit role by default', function() { - var node = document.createElement('li'); - flatTreeSetup(node); + fixture.innerHTML = ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); assert.equal(aria.getRole(node), 'listitem'); }); @@ -87,6 +261,14 @@ describe('aria.getRole', function() { assert.isNull(aria.getRole(node, { noImplicit: true })); }); + it('does not do role resolution if noImplicit: true', function() { + var node = document.createElement('li'); + node.setAttribute('role', 'none'); + node.setAttribute('aria-label', 'foo'); + flatTreeSetup(node); + assert.equal(aria.getRole(node, { noImplicit: true }), null); + }); + it('still returns the explicit role', function() { var node = document.createElement('li'); node.setAttribute('role', 'button'); @@ -95,17 +277,18 @@ describe('aria.getRole', function() { }); it('returns the implicit role with `noImplicit: false`', function() { - var node = document.createElement('li'); - flatTreeSetup(node); + fixture.innerHTML = ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); assert.equal(aria.getRole(node, { noImplicit: false }), 'listitem'); }); }); describe('abstracts', function() { it('ignores abstract roles by default', function() { - var node = document.createElement('li'); - node.setAttribute('role', 'section'); - flatTreeSetup(node); + fixture.innerHTML = ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); assert.equal(roleDefinitions.section.type, 'abstract'); assert.equal(aria.getRole(node), 'listitem'); }); @@ -119,9 +302,9 @@ describe('aria.getRole', function() { }); it('does not returns abstract roles with `abstracts: false`', function() { - var node = document.createElement('li'); - node.setAttribute('role', 'section'); - flatTreeSetup(node); + fixture.innerHTML = ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); assert.equal(roleDefinitions.section.type, 'abstract'); assert.equal(aria.getRole(node, { abstracts: false }), 'listitem'); }); @@ -173,9 +356,10 @@ describe('aria.getRole', function() { }); it('respects the defaults', function() { - var node = document.createElement('li'); - node.setAttribute('role', 'doc-chapter section'); - flatTreeSetup(node); + fixture.innerHTML = + ''; + flatTreeSetup(fixture); + var node = fixture.querySelector('#target'); assert.equal(aria.getRole(node, { fallback: true }), 'listitem'); }); diff --git a/test/commons/aria/implicit-role.js b/test/commons/aria/implicit-role.js index 41ef00ff2a..613ec74ed2 100644 --- a/test/commons/aria/implicit-role.js +++ b/test/commons/aria/implicit-role.js @@ -159,11 +159,25 @@ describe('aria.implicitRole', function() { assert.equal(implicitRole(node), 'img'); }); - it('should return null for "img" with empty alt', function() { + it('should return presentation for "img" with empty alt', function() { fixture.innerHTML = ''; var node = fixture.querySelector('#target'); flatTreeSetup(fixture); - assert.isNull(implicitRole(node)); + assert.equal(implicitRole(node), 'presentation'); + }); + + it('should return img for "img" with empty alt and global aria attribute', function() { + fixture.innerHTML = ''; + var node = fixture.querySelector('#target'); + flatTreeSetup(fixture); + assert.equal(implicitRole(node), 'img'); + }); + + it('should return img for "img" with empty alt and focusable', function() { + fixture.innerHTML = ''; + var node = fixture.querySelector('#target'); + flatTreeSetup(fixture); + assert.equal(implicitRole(node), 'img'); }); it('should return button for "input[type=button]"', function() { diff --git a/test/commons/dom/get-element-stack.js b/test/commons/dom/get-element-stack.js index 74d1d8ebdf..52b129302f 100644 --- a/test/commons/dom/get-element-stack.js +++ b/test/commons/dom/get-element-stack.js @@ -562,5 +562,19 @@ describe('dom.getElementStack', function() { var stacks = getTextElementStack(target).map(mapToIDs); assert.deepEqual(stacks, [['target', '1', 'fixture']]); }); + + it('should handle truncated text', function() { + fixture.innerHTML = + '
' + + '
' + + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed et sollicitudin quam. Fusce mi odio, egestas pulvinar erat eget, vehicula tempus est. Proin vitae ullamcorper velit. Donec sagittis est justo, mattis iaculis arcu facilisis id. Proin pulvinar ornare arcu a fermentum. Quisque et dignissim nulla, sit amet consectetur ipsum. Donec in libero porttitor, dapibus neque imperdiet, aliquam est. Vivamus blandit volutpat fringilla. In mi magna, mollis sit amet imperdiet eu, rutrum ut tellus. Mauris vel condimentum nibh, quis ultricies nisi. Vivamus accumsan quam mauris, id iaculis quam fringilla ac. Curabitur pulvinar dolor ac magna vehicula, non auctor ligula dignissim. Nam ac nibh porttitor, malesuada tortor varius, feugiat turpis. Mauris dapibus, tellus ut viverra porta, ipsum turpis bibendum ligula, at tempor felis ante non libero. Donec dapibus, diam sit amet posuere commodo, magna orci hendrerit ipsum, eu egestas mauris nulla ut ipsum. Sed luctus, orci in fringilla finibus, odio leo porta dolor, eu dignissim risus eros eget erat.' + + 'World' + + '
' + + '
'; + axe.testUtils.flatTreeSetup(fixture); + var target = fixture.querySelector('#target'); + var stacks = getTextElementStack(target).map(mapToIDs); + assert.deepEqual(stacks, [['target', '1', 'fixture']]); + }); }); }); diff --git a/test/commons/matches/from-primative.js b/test/commons/matches/from-primative.js index e7173789ba..888482d1b0 100644 --- a/test/commons/matches/from-primative.js +++ b/test/commons/matches/from-primative.js @@ -77,4 +77,13 @@ describe('matches.fromPrimative', function() { assert.isFalse(fromPrimative('foobar', /^(foo|bar|baz)$/)); }); }); + + describe('with RegExp string', function() { + it('returns true if the regexp matches', function() { + assert.isTrue(fromPrimative('bar', '/^(foo|bar|baz)$/')); + }); + it('returns false if the regexp does not match', function() { + assert.isFalse(fromPrimative('foobar', '/^(foo|bar|baz)$/')); + }); + }); }); diff --git a/test/commons/text/form-control-value.js b/test/commons/text/form-control-value.js index d32138b132..4fcfe0e514 100644 --- a/test/commons/text/form-control-value.js +++ b/test/commons/text/form-control-value.js @@ -28,13 +28,6 @@ describe('text.formControlValue', function() { assert.equal(formControlValue(target, { startNode: target }), ''); }); - it('returns `` when the role is not supposed to return a value', function() { - var target = queryFixture( - '' - ); - assert.equal(formControlValue(target), ''); - }); - it('returns `` when accessibleNameFromFieldValue says the role is unsupported', function() { var target = queryFixture( '' diff --git a/test/commons/text/native-text-alternative.js b/test/commons/text/native-text-alternative.js index b0e3a3dd48..c08f5f545b 100644 --- a/test/commons/text/native-text-alternative.js +++ b/test/commons/text/native-text-alternative.js @@ -35,13 +35,13 @@ describe('text.nativeTextAlternative', function() { it('returns `` when the element has role=presentation', function() { var vNode = queryFixture( - '' + 'foo' ); assert.equal(nativeTextAlternative(vNode), ''); }); it('returns `` when the element has role=none', function() { - var vNode = queryFixture(''); + var vNode = queryFixture('foo'); assert.equal(nativeTextAlternative(vNode), ''); }); }); diff --git a/test/core/public/configure.js b/test/core/public/configure.js index 773837b2a0..6335e4e372 100644 --- a/test/core/public/configure.js +++ b/test/core/public/configure.js @@ -908,4 +908,107 @@ describe('axe.configure', function() { }); }); }); + + describe('given a standards object', function() { + beforeEach(function() { + axe._load({}); + }); + + describe('ariaAttrs', function() { + it('should allow creating new attr', function() { + axe.configure({ + standards: { + ariaAttrs: { + newAttr: { + type: 'string' + } + } + } + }); + + var ariaAttr = axe._audit.standards.ariaAttrs.newAttr; + assert.equal(ariaAttr.type, 'string'); + }); + + it('should override existing attr', function() { + axe.configure({ + standards: { + ariaAttrs: { + newAttr: { + type: 'string' + } + } + } + }); + + axe.configure({ + standards: { + ariaAttrs: { + newAttr: { + type: 'mntoken', + values: ['foo', 'bar'] + } + } + } + }); + + var ariaAttr = axe._audit.standards.ariaAttrs.newAttr; + assert.equal(ariaAttr.type, 'mntoken'); + assert.deepEqual(ariaAttr.values, ['foo', 'bar']); + }); + + it('should merge existing attr', function() { + axe.configure({ + standards: { + ariaAttrs: { + newAttr: { + type: 'mntoken', + values: ['foo', 'bar'] + } + } + } + }); + + axe.configure({ + standards: { + ariaAttrs: { + newAttr: { + type: 'mntokens' + } + } + } + }); + + var ariaAttr = axe._audit.standards.ariaAttrs.newAttr; + assert.equal(ariaAttr.type, 'mntokens'); + assert.deepEqual(ariaAttr.values, ['foo', 'bar']); + }); + + it('should override and not merge array', function() { + axe.configure({ + standards: { + ariaAttrs: { + newAttr: { + type: 'mntoken', + values: ['foo', 'bar'] + } + } + } + }); + + axe.configure({ + standards: { + ariaAttrs: { + newAttr: { + values: ['baz'] + } + } + } + }); + + var ariaAttr = axe._audit.standards.ariaAttrs.newAttr; + assert.deepEqual(ariaAttr.values, ['baz']); + }); + }); + }); }); diff --git a/test/core/public/reset.js b/test/core/public/reset.js index 8210c11919..38412fc853 100644 --- a/test/core/public/reset.js +++ b/test/core/public/reset.js @@ -123,4 +123,23 @@ describe('axe.reset', function() { assert.equal(banana.messages.incomplete, 'donno'); }); }); + + it('should restore standards object', function() { + axe._load({}); + + axe.configure({ + standards: { + ariaAttrs: { + 'aria-live': { + type: 'string' + } + } + } + }); + + axe.reset(); + + var ariaLiveAttr = axe._audit.standards.ariaAttrs['aria-live']; + assert.equal(ariaLiveAttr.type, 'nmtoken'); + }); });