/** * 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, 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', '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 { const implicitRole = kImplicitRoleByTagName[element.tagName]?.(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 // https://www.w3.org/TR/wai-aria-1.2/#aria-hidden export function isElementHiddenForAria(element: Element, cache: Map): boolean { if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName)) return true; // Note: