@markuplint/ml-spec
Version:
Types and schema that specs of the Markup languages for markuplint
211 lines (210 loc) • 7.39 kB
JavaScript
import { ariaSpecs } from '../specs/aria-specs.js';
import { getSpecByTagName } from '../specs/get-spec-by-tag-name.js';
import { isPresentational } from '../specs/is-presentational.js';
import { resolveNamespace } from '../utils/resolve-namespace.js';
import { getComputedRole } from './get-computed-role.js';
/**
* Detect including/excluding from the Accessibility Tree
*
* @see https://www.w3.org/TR/wai-aria-1.2/#accessibility_tree
*
* @param specs
* @param el
* @param version
*/
export function isExposed(
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
el, specs, version) {
// According to WAI-ARIA
if (isExcluding(el, specs, version)) {
return false;
}
// Return true if the element is unknown, deprecated, or obsolete.
const { localName, namespace } = resolveNamespace(el.localName, el.namespaceURI);
const spec = getSpecByTagName(specs.specs, localName, namespace);
if (!spec || spec.deprecated || spec.obsolete != null) {
return true;
}
// According to HTML and SVG Specs with **the author's interpretation**
{
if (!isExposedElement(el, specs)) {
return false;
}
}
// According to WAI-ARIA
{
const exposable = isIncluding(el, specs, version);
if (exposable) {
return true;
}
}
// Default
return true;
}
/**
* Excluding Elements from the Accessibility Tree
*
* @see https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
*
* @param specs
* @param el
* @param version
*/
function isExcluding(
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
el, specs, version) {
/**
* The following elements are not exposed via the accessibility API and
* user agents MUST NOT include them in the accessibility tree:
* - Elements, including their descendent elements,
* that have host language semantics specifying
* that the element is not displayed, such as CSS display:none,
* visibility:hidden, or the HTML hidden attribute.
*/
{
let currentEl = el;
while (currentEl) {
if (hasDisplayNodeOrVisibilityHidden(currentEl) || currentEl.hasAttribute('hidden')) {
return true;
}
currentEl = currentEl.parentElement;
}
}
/**
* - Elements with none or presentation as the first role in the role attribute.
* However, their exclusion is conditional. In addition,
* the element's descendants and text content are generally included.
* These exceptions and conditions are documented in the presentation (role)
* section.
*/
if (isPresentational((el.getAttribute('role') ?? '').split(/\s+/)[0])) {
return true;
}
/**
* If not already excluded from the accessibility tree per the above rules,
* user agents SHOULD NOT include the following elements
* in the accessibility tree:
* - Elements, including their descendants, that have aria-hidden set to true.
* In other words, aria-hidden="true" on a parent overrides aria-hidden="false"
* on descendants.
*/
{
let currentEl = el;
while (currentEl) {
if (currentEl.getAttribute('aria-hidden') === 'true') {
return true;
}
currentEl = currentEl.parentElement;
}
}
/**
* - Any descendants of elements that have the characteristic
* "Children Presentational: True" unless the descendant
* is not allowed to be presentational because it meets one of
* the conditions for exception described in
* Presentational Roles Conflict Resolution.
* However, the text content of any excluded descendants is included.
*/
{
let currentEl = el.parentElement;
while (currentEl) {
const { role } = getComputedRole(specs, currentEl, version);
if (role?.childrenPresentational) {
return true;
}
currentEl = currentEl.parentElement;
}
}
return false;
}
/**
* Including Elements in the Accessibility Tree
*
* If not excluded from or marked as hidden in the accessibility tree
* per the rules above in Excluding Elements in the Accessibility Tree,
* user agents MUST provide an accessible object in the accessibility tree
* for DOM elements that meet any of the following criteria:
*
* @see https://www.w3.org/TR/wai-aria-1.2/#tree_inclusion
*
* @param specs
* @param el
* @param version
*/
function isIncluding(
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
el, specs, version) {
/**
* > **meet any** of the following criteria:
*/
const results = [];
/**
* Elements that are not hidden and may fire an accessibility API event,
* including:
* - Elements that are currently focused, even if the element or one of
* its ancestor elements has its aria-hidden attribute set to true.
*/
// 🚫 Can't detect the element is focused.
// results.push(true);
/**
* - Elements that are a valid target of an aria-activedescendant attribute.
*/
// TODO: Compute aria-activedescendant.
// results.push(true);
/**
* Elements that have an explicit role or a global WAI-ARIA attribute and
* do not have aria-hidden set to true.
* (See Excluding Elements in the Accessibility Tree for
* additional guidance on aria-hidden.)
*/
if (el.getAttribute('aria-hidden') !== 'true') {
const globalAria = ariaSpecs(specs, version).props.filter(prop => prop.isGlobal);
const { role } = getComputedRole(specs, el, version);
// Has an explicit role
if (role && !role.isImplicit) {
results.push(true);
}
// Has a global WAI-ARIA attribute
for (const attr of el.attributes) {
if (globalAria.some(aria => aria.name === attr.localName)) {
results.push(true);
break;
}
}
}
/**
* Elements that are not hidden and have an ID that is referenced
* by another element via a WAI-ARIA property.
*/
// TODO: Compute refering ID.
// results.push(true);
return results.includes(true);
}
function hasDisplayNodeOrVisibilityHidden(
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
el) {
const style = el.getAttribute('style');
if (!style) {
return false;
}
// TODO: Improve accuracy
return /display\s*:\s*none|visibility\s*:\s*hidden/i.test(style);
}
function isExposedElement(
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
el, specs) {
const svgRenderedConditions = specs.def['#contentModels']['#SVGRenderable']?.join(',');
if (svgRenderedConditions && el.matches(svgRenderedConditions)) {
return true;
}
return isNotMetaOrHiddenHTMLElement(el, specs);
}
function isNotMetaOrHiddenHTMLElement(
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
el, specs) {
const metadataConditions = specs.def['#contentModels']['#metadata']?.join(',');
if (metadataConditions && el.matches(metadataConditions)) {
return false;
}
return !el.matches('input[type=hidden i]');
}