/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { closestCrossShadow, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils'; function hasExplicitAccessibleName(e: Element) { return e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby'); } // https://www.w3.org/TR/wai-aria-practices/examples/landmarks/HTML5.html const kAncestorPreventingLandmark = 'article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]'; // https://www.w3.org/TR/wai-aria-1.2/#global_states const kGlobalAriaAttributes = [ 'aria-atomic', 'aria-busy', 'aria-controls', 'aria-current', 'aria-describedby', 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns', 'aria-relevant', 'aria-roledescription', ]; function hasGlobalAriaAttribute(e: Element) { return kGlobalAriaAttributes.some(a => e.hasAttribute(a)); } // https://w3c.github.io/html-aam/#html-element-role-mappings const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null } = { 'A': (e: Element) => { return e.hasAttribute('href') ? 'link' : null; }, 'AREA': (e: Element) => { return e.hasAttribute('href') ? 'link' : null; }, 'ARTICLE': () => 'article', 'ASIDE': () => 'complementary', 'BLOCKQUOTE': () => 'blockquote', 'BUTTON': () => 'button', 'CAPTION': () => 'caption', 'CODE': () => 'code', 'DATALIST': () => 'listbox', 'DD': () => 'definition', 'DEL': () => 'deletion', 'DETAILS': () => 'group', 'DFN': () => 'term', 'DIALOG': () => 'dialog', 'DT': () => 'term', 'EM': () => 'emphasis', 'FIELDSET': () => 'group', 'FIGURE': () => 'figure', 'FOOTER': (e: Element) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'contentinfo', 'FORM': (e: Element) => hasExplicitAccessibleName(e) ? 'form' : null, 'H1': () => 'heading', 'H2': () => 'heading', 'H3': () => 'heading', 'H4': () => 'heading', 'H5': () => 'heading', 'H6': () => 'heading', 'HEADER': (e: Element) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'banner', 'HR': () => 'separator', 'HTML': () => 'document', 'IMG': (e: Element) => (e.getAttribute('alt') === '') && !hasGlobalAriaAttribute(e) && Number.isNaN(Number(String(e.getAttribute('tabindex')))) ? 'presentation' : 'img', 'INPUT': (e: Element) => { const type = (e as HTMLInputElement).type.toLowerCase(); if (type === 'search') return e.hasAttribute('list') ? 'combobox' : 'searchbox'; if (['email', 'tel', 'text', 'url', ''].includes(type)) { // https://html.spec.whatwg.org/multipage/input.html#concept-input-list const list = getIdRefs(e, e.getAttribute('list'))[0]; return (list && list.tagName === 'DATALIST') ? 'combobox' : 'textbox'; } if (type === 'hidden') return ''; return { 'button': 'button', 'checkbox': 'checkbox', 'image': 'button', 'number': 'spinbutton', 'radio': 'radio', 'range': 'slider', 'reset': 'button', 'submit': 'button', }[type] || 'textbox'; }, 'INS': () => 'insertion', 'LI': () => 'listitem', 'MAIN': () => 'main', 'MARK': () => 'mark', 'MATH': () => 'math', 'MENU': () => 'list', 'METER': () => 'meter', 'NAV': () => 'navigation', 'OL': () => 'list', 'OPTGROUP': () => 'group', 'OPTION': () => 'option', 'OUTPUT': () => 'status', 'P': () => 'paragraph', 'PROGRESS': () => 'progressbar', 'SECTION': (e: Element) => hasExplicitAccessibleName(e) ? 'region' : null, 'SELECT': (e: Element) => e.hasAttribute('multiple') || (e as HTMLSelectElement).size > 1 ? 'listbox' : 'combobox', 'STRONG': () => 'strong', 'SUB': () => 'subscript', 'SUP': () => 'superscript', // For we default to Chrome behavior: // - Chrome reports 'img'. // - Firefox reports 'diagram' that is not in official ARIA spec yet. // - Safari reports 'no role', but still computes accessible name. 'SVG': () => 'img', 'TABLE': () => 'table', 'TBODY': () => 'rowgroup', 'TD': (e: Element) => { const table = closestCrossShadow(e, 'table'); const role = table ? getExplicitAriaRole(table) : ''; return (role === 'grid' || role === 'treegrid') ? 'gridcell' : 'cell'; }, 'TEXTAREA': () => 'textbox', 'TFOOT': () => 'rowgroup', 'TH': (e: Element) => { if (e.getAttribute('scope') === 'col') return 'columnheader'; if (e.getAttribute('scope') === 'row') return 'rowheader'; const table = closestCrossShadow(e, 'table'); const role = table ? getExplicitAriaRole(table) : ''; return (role === 'grid' || role === 'treegrid') ? 'gridcell' : 'cell'; }, 'THEAD': () => 'rowgroup', 'TIME': () => 'time', 'TR': () => 'row', 'UL': () => 'list', }; const kPresentationInheritanceParents: { [tagName: string]: string[] } = { 'DD': ['DL', 'DIV'], 'DIV': ['DL'], 'DT': ['DL', 'DIV'], 'LI': ['OL', 'UL'], 'TBODY': ['TABLE'], 'TD': ['TR'], 'TFOOT': ['TABLE'], 'TH': ['TR'], 'THEAD': ['TABLE'], 'TR': ['THEAD', 'TBODY', 'TFOOT', 'TABLE'], }; function getImplicitAriaRole(element: Element): string | null { // Elements from the svg namespace do not have uppercase tagName. const implicitRole = kImplicitRoleByTagName[element.tagName.toUpperCase()]?.(element) || ''; if (!implicitRole) return null; // Inherit presentation role when required. // https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none let ancestor: Element | null = element; while (ancestor) { const parent = parentElementOrShadowHost(ancestor); const parents = kPresentationInheritanceParents[ancestor.tagName]; if (!parents || !parent || !parents.includes(parent.tagName)) break; const parentExplicitRole = getExplicitAriaRole(parent); if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent)) return parentExplicitRole; ancestor = parent; } return implicitRole; } // https://www.w3.org/TR/wai-aria-1.2/#role_definitions const allRoles = [ 'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'command', 'complementary', 'composite', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid', 'gridcell', 'group', 'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'meter', 'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup', 'range', 'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'section', 'sectionhead', 'select', 'separator', 'slider', 'spinbutton', 'status', 'strong', 'structure', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'widget', 'window' ]; // https://www.w3.org/TR/wai-aria-1.2/#abstract_roles const abstractRoles = ['command', 'composite', 'input', 'landmark', 'range', 'roletype', 'section', 'sectionhead', 'select', 'structure', 'widget', 'window']; const validRoles = allRoles.filter(role => !abstractRoles.includes(role)); function getExplicitAriaRole(element: Element): string | null { // https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles const roles = (element.getAttribute('role') || '').split(' ').map(role => role.trim()); return roles.find(role => validRoles.includes(role)) || null; } function hasPresentationConflictResolution(element: Element) { // https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none // TODO: this should include "|| focusable" check. return !hasGlobalAriaAttribute(element); } export function getAriaRole(element: Element): string | null { const explicitRole = getExplicitAriaRole(element); if (!explicitRole) return getImplicitAriaRole(element); if ((explicitRole === 'none' || explicitRole === 'presentation') && hasPresentationConflictResolution(element)) return getImplicitAriaRole(element); return explicitRole; } function getAriaBoolean(attr: string | null) { return attr === null ? undefined : attr.toLowerCase() === 'true'; } // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion, but including "none" and "presentation" roles // Not implemented: // `Any descendants of elements that have the characteristic "Children Presentational: True"` // https://www.w3.org/TR/wai-aria-1.2/#aria-hidden export function isElementHiddenForAria(element: Element): boolean { if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName)) return true; const style = getElementComputedStyle(element); const isSlot = element.nodeName === 'SLOT'; if (style?.display === 'contents' && !isSlot) { // display:contents is not rendered itself, but its child nodes are. for (let child = element.firstChild; child; child = child.nextSibling) { if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && !isElementHiddenForAria(child as Element)) return false; if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text)) return false; } return true; } // Note: , but all browsers actually support it. const summary = element.getAttribute('summary') || ''; if (summary) return summary; // SPEC DIFFERENCE. // Spec says "if the table element has a title attribute, then use that attribute". // We ignore title to pass "name_from_content-manual.html". } // https://w3c.github.io/html-aam/#area-element if (element.tagName === 'AREA') { options.visitedElements.add(element); const alt = element.getAttribute('alt') || ''; if (alt.trim()) return alt; const title = element.getAttribute('title') || ''; return title; } // https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd if (element.tagName.toUpperCase() === 'SVG' || (element as SVGElement).ownerSVGElement) { options.visitedElements.add(element); for (let child = element.firstElementChild; child; child = child.nextElementSibling) { if (child.tagName.toUpperCase() === 'TITLE' && (child as SVGElement).ownerSVGElement) { return getElementAccessibleNameInternal(child, { ...childOptions, embeddedInLabelledBy: 'self', }); } } } if ((element as SVGElement).ownerSVGElement && element.tagName.toUpperCase() === 'A') { const title = element.getAttribute('xlink:title') || ''; if (title.trim()) { options.visitedElements.add(element); return title; } } } // step 2f + step 2h. if (allowsNameFromContent(role, options.embeddedInTargetElement === 'descendant') || options.embeddedInLabelledBy !== 'none' || options.embeddedInLabel !== 'none' || options.embeddedInTextAlternativeElement) { options.visitedElements.add(element); const tokens: string[] = []; const visit = (node: Node, skipSlotted: boolean) => { if (skipSlotted && (node as Element | Text).assignedSlot) return; if (node.nodeType === 1 /* Node.ELEMENT_NODE */) { const display = getElementComputedStyle(node as Element)?.display || 'inline'; let token = getElementAccessibleNameInternal(node as Element, childOptions); // SPEC DIFFERENCE. // Spec says "append the result to the accumulated text", assuming "with space". // However, multiple tests insist that inline elements do not add a space. // Additionally,
insists on a space anyway, see "name_file-label-inline-block-elements-manual.html" if (display !== 'inline' || node.nodeName === 'BR') token = ' ' + token + ' '; tokens.push(token); } else if (node.nodeType === 3 /* Node.TEXT_NODE */) { // step 2g. tokens.push(node.textContent || ''); } }; tokens.push(getPseudoContent(getElementComputedStyle(element, '::before'))); const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : []; if (assignedNodes.length) { for (const child of assignedNodes) visit(child, false); } else { for (let child = element.firstChild; child; child = child.nextSibling) visit(child, true); if (element.shadowRoot) { for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) visit(child, true); } for (const owned of getIdRefs(element, element.getAttribute('aria-owns'))) visit(owned, true); } tokens.push(getPseudoContent(getElementComputedStyle(element, '::after'))); const accessibleName = tokens.join(''); if (accessibleName.trim()) return accessibleName; } // step 2i. if (!['presentation', 'none'].includes(role) || element.tagName === 'IFRAME') { options.visitedElements.add(element); const title = element.getAttribute('title') || ''; if (title.trim()) return title; } options.visitedElements.add(element); return ''; } export const kAriaSelectedRoles = ['gridcell', 'option', 'row', 'tab', 'rowheader', 'columnheader', 'treeitem']; export function getAriaSelected(element: Element): boolean { // https://www.w3.org/TR/wai-aria-1.2/#aria-selected // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings if (element.tagName === 'OPTION') return (element as HTMLOptionElement).selected; if (kAriaSelectedRoles.includes(getAriaRole(element) || '')) return getAriaBoolean(element.getAttribute('aria-selected')) === true; return false; } export const kAriaCheckedRoles = ['checkbox', 'menuitemcheckbox', 'option', 'radio', 'switch', 'menuitemradio', 'treeitem']; export function getAriaChecked(element: Element): boolean | 'mixed' { const result = getChecked(element, true); return result === 'error' ? false : result; } export function getChecked(element: Element, allowMixed: boolean): boolean | 'mixed' | 'error' { // https://www.w3.org/TR/wai-aria-1.2/#aria-checked // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings if (allowMixed && element.tagName === 'INPUT' && (element as HTMLInputElement).indeterminate) return 'mixed'; if (element.tagName === 'INPUT' && ['checkbox', 'radio'].includes((element as HTMLInputElement).type)) return (element as HTMLInputElement).checked; if (kAriaCheckedRoles.includes(getAriaRole(element) || '')) { const checked = element.getAttribute('aria-checked'); if (checked === 'true') return true; if (allowMixed && checked === 'mixed') return 'mixed'; return false; } return 'error'; } export const kAriaPressedRoles = ['button']; export function getAriaPressed(element: Element): boolean | 'mixed' { // https://www.w3.org/TR/wai-aria-1.2/#aria-pressed if (kAriaPressedRoles.includes(getAriaRole(element) || '')) { const pressed = element.getAttribute('aria-pressed'); if (pressed === 'true') return true; if (pressed === 'mixed') return 'mixed'; } return false; } export const kAriaExpandedRoles = ['application', 'button', 'checkbox', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem', 'row', 'rowheader', 'tab', 'treeitem', 'columnheader', 'menuitemcheckbox', 'menuitemradio', 'rowheader', 'switch']; export function getAriaExpanded(element: Element): boolean | 'none' { // https://www.w3.org/TR/wai-aria-1.2/#aria-expanded // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings if (element.tagName === 'DETAILS') return (element as HTMLDetailsElement).open; if (kAriaExpandedRoles.includes(getAriaRole(element) || '')) { const expanded = element.getAttribute('aria-expanded'); if (expanded === null) return 'none'; if (expanded === 'true') return true; return false; } return 'none'; } export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem']; export function getAriaLevel(element: Element): number { // https://www.w3.org/TR/wai-aria-1.2/#aria-level // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[element.tagName]; if (native) return native; if (kAriaLevelRoles.includes(getAriaRole(element) || '')) { const attr = element.getAttribute('aria-level'); const value = attr === null ? Number.NaN : Number(attr); if (Number.isInteger(value) && value >= 1) return value; } return 0; } export const kAriaDisabledRoles = ['application', 'button', 'composite', 'gridcell', 'group', 'input', 'link', 'menuitem', 'scrollbar', 'separator', 'tab', 'checkbox', 'columnheader', 'combobox', 'grid', 'listbox', 'menu', 'menubar', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'radiogroup', 'row', 'rowheader', 'searchbox', 'select', 'slider', 'spinbutton', 'switch', 'tablist', 'textbox', 'toolbar', 'tree', 'treegrid', 'treeitem']; export function getAriaDisabled(element: Element): boolean { // https://www.w3.org/TR/wai-aria-1.2/#aria-disabled // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings // Note that aria-disabled applies to all descendants, so we look up the hierarchy. const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(element.tagName); if (isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledFieldSet(element))) return true; return hasExplicitAriaDisabled(element); } function belongsToDisabledFieldSet(element: Element | null): boolean { if (!element) return false; if (element.tagName === 'FIELDSET' && element.hasAttribute('disabled')) return true; // fieldset does not work across shadow boundaries. return belongsToDisabledFieldSet(element.parentElement); } function hasExplicitAriaDisabled(element: Element | undefined): boolean { if (!element) return false; if (kAriaDisabledRoles.includes(getAriaRole(element) || '')) { const attribute = (element.getAttribute('aria-disabled') || '').toLowerCase(); if (attribute === 'true') return true; if (attribute === 'false') return false; } // aria-disabled works across shadow boundaries. return hasExplicitAriaDisabled(parentElementOrShadowHost(element)); } let cacheAccessibleName: Map | undefined; let cacheAccessibleNameHidden: Map | undefined; let cacheIsHidden: Map | undefined; let cachesCounter = 0; export function beginAriaCaches() { ++cachesCounter; cacheAccessibleName ??= new Map(); cacheAccessibleNameHidden ??= new Map(); cacheIsHidden ??= new Map(); } export function endAriaCaches() { if (!--cachesCounter) { cacheAccessibleName = undefined; cacheAccessibleNameHidden = undefined; cacheIsHidden = undefined; } }