2022-03-21 17:26:45 -07:00
/ * *
* 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 .
* /
2025-05-08 17:42:13 +00:00
import * as css from '@isomorphic/cssTokenizer' ;
2025-04-08 08:02:19 +00:00
import { getGlobalOptions , closestCrossShadow , elementSafeTagName , enclosingShadowRootOrDocument , getElementComputedStyle , isElementStyleVisibilityVisible , isVisibleTextNode , parentElementOrShadowHost } from './domUtils' ;
2022-03-21 17:26:45 -07:00
2025-02-07 13:54:01 -08:00
import type { AriaRole } from '@isomorphic/ariaSnapshot' ;
2022-03-21 17:26:45 -07:00
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
2025-03-25 13:49:28 +00:00
const kGlobalAriaAttributes : [ string , string [ ] | undefined ] [ ] = [
2024-04-18 08:53:31 -07:00
[ 'aria-atomic' , undefined ] ,
[ 'aria-busy' , undefined ] ,
[ 'aria-controls' , undefined ] ,
[ 'aria-current' , undefined ] ,
[ 'aria-describedby' , undefined ] ,
[ 'aria-details' , undefined ] ,
// Global use deprecated in ARIA 1.2
// ['aria-disabled', undefined],
[ 'aria-dropeffect' , undefined ] ,
// Global use deprecated in ARIA 1.2
// ['aria-errormessage', undefined],
[ 'aria-flowto' , undefined ] ,
[ 'aria-grabbed' , undefined ] ,
// Global use deprecated in ARIA 1.2
// ['aria-haspopup', undefined],
[ 'aria-hidden' , undefined ] ,
// Global use deprecated in ARIA 1.2
// ['aria-invalid', undefined],
[ 'aria-keyshortcuts' , undefined ] ,
2025-03-25 13:49:28 +00:00
[ 'aria-label' , [ 'caption' , 'code' , 'deletion' , 'emphasis' , 'generic' , 'insertion' , 'paragraph' , 'presentation' , 'strong' , 'subscript' , 'superscript' ] ] ,
[ 'aria-labelledby' , [ 'caption' , 'code' , 'deletion' , 'emphasis' , 'generic' , 'insertion' , 'paragraph' , 'presentation' , 'strong' , 'subscript' , 'superscript' ] ] ,
2024-04-18 08:53:31 -07:00
[ 'aria-live' , undefined ] ,
[ 'aria-owns' , undefined ] ,
[ 'aria-relevant' , undefined ] ,
2025-03-25 13:49:28 +00:00
[ 'aria-roledescription' , [ 'generic' ] ] ,
] ;
2024-04-18 08:53:31 -07:00
function hasGlobalAriaAttribute ( element : Element , forRole? : string | null ) {
2025-03-25 13:49:28 +00:00
return kGlobalAriaAttributes . some ( ( [ attr , prohibited ] ) = > {
return ! prohibited ? . includes ( forRole || '' ) && element . hasAttribute ( attr ) ;
2024-04-18 08:53:31 -07:00
} ) ;
}
function hasTabIndex ( element : Element ) {
return ! Number . isNaN ( Number ( String ( element . getAttribute ( 'tabindex' ) ) ) ) ;
}
2022-03-21 17:26:45 -07:00
2024-04-18 08:53:31 -07:00
function isFocusable ( element : Element ) {
// TODO:
// - "inert" attribute makes the whole substree not focusable
// - when dialog is open on the page - everything but the dialog is not focusable
return ! isNativelyDisabled ( element ) && ( isNativelyFocusable ( element ) || hasTabIndex ( element ) ) ;
}
function isNativelyFocusable ( element : Element ) {
2024-05-02 09:42:19 -07:00
const tagName = elementSafeTagName ( element ) ;
2024-04-19 12:49:49 -07:00
if ( [ 'BUTTON' , 'DETAILS' , 'SELECT' , 'TEXTAREA' ] . includes ( tagName ) )
2024-04-18 08:53:31 -07:00
return true ;
if ( tagName === 'A' || tagName === 'AREA' )
return element . hasAttribute ( 'href' ) ;
if ( tagName === 'INPUT' )
return ! ( element as HTMLInputElement ) . hidden ;
return false ;
2022-03-21 17:26:45 -07:00
}
// https://w3c.github.io/html-aam/#html-element-role-mappings
2024-04-19 12:49:49 -07:00
// https://www.w3.org/TR/html-aria/#docconformance
2024-10-18 20:18:18 -07:00
const kImplicitRoleByTagName : { [ tagName : string ] : ( e : Element ) = > AriaRole | null } = {
2022-03-21 17:26:45 -07:00
'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' ,
2024-04-19 12:49:49 -07:00
'IMG' : ( e : Element ) = > ( e . getAttribute ( 'alt' ) === '' ) && ! e . getAttribute ( 'title' ) && ! hasGlobalAriaAttribute ( e ) && ! hasTabIndex ( e ) ? 'presentation' : 'img' ,
2022-03-21 17:26:45 -07:00
'INPUT' : ( e : Element ) = > {
const type = ( e as HTMLInputElement ) . type . toLowerCase ( ) ;
if ( type === 'search' )
return e . hasAttribute ( 'list' ) ? 'combobox' : 'searchbox' ;
2022-03-28 15:22:50 -07:00
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 ] ;
2024-05-02 09:42:19 -07:00
return ( list && elementSafeTagName ( list ) === 'DATALIST' ) ? 'combobox' : 'textbox' ;
2022-03-28 15:22:50 -07:00
}
2022-03-21 17:26:45 -07:00
if ( type === 'hidden' )
2024-10-18 20:18:18 -07:00
return null ;
2025-04-08 08:02:19 +00:00
// File inputs do not have a role by the spec: https://www.w3.org/TR/html-aam-1.0/#el-input-file.
// However, there are open issues about fixing it: https://github.com/w3c/aria/issues/1926.
// All browsers report it as a button, and it is rendered as a button, so we do "button".
if ( type === 'file' && ! getGlobalOptions ( ) . inputFileRoleTextbox )
return 'button' ;
2024-10-18 20:18:18 -07:00
return inputTypeToRole [ type ] || 'textbox' ;
2022-03-21 17:26:45 -07:00
} ,
'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' ,
2023-03-28 15:52:16 -07:00
// For <svg> 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' ,
2022-03-21 17:26:45 -07:00
'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' ] ,
} ;
2024-10-18 20:18:18 -07:00
function getImplicitAriaRole ( element : Element ) : AriaRole | null {
2024-05-02 09:42:19 -07:00
const implicitRole = kImplicitRoleByTagName [ elementSafeTagName ( element ) ] ? . ( element ) || '' ;
2022-03-21 17:26:45 -07:00
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 ) ;
2024-05-02 09:42:19 -07:00
const parents = kPresentationInheritanceParents [ elementSafeTagName ( ancestor ) ] ;
if ( ! parents || ! parent || ! parents . includes ( elementSafeTagName ( parent ) ) )
2022-03-21 17:26:45 -07:00
break ;
const parentExplicitRole = getExplicitAriaRole ( parent ) ;
2024-04-18 08:53:31 -07:00
if ( ( parentExplicitRole === 'none' || parentExplicitRole === 'presentation' ) && ! hasPresentationConflictResolution ( parent , parentExplicitRole ) )
2022-03-21 17:26:45 -07:00
return parentExplicitRole ;
ancestor = parent ;
}
return implicitRole ;
}
2024-10-18 20:18:18 -07:00
const validRoles : AriaRole [ ] = [ 'alert' , 'alertdialog' , 'application' , 'article' , 'banner' , 'blockquote' , 'button' , 'caption' , 'cell' , 'checkbox' , 'code' , 'columnheader' , 'combobox' ,
'complementary' , 'contentinfo' , 'definition' , 'deletion' , 'dialog' , 'directory' , 'document' , 'emphasis' , 'feed' , 'figure' , 'form' , 'generic' , 'grid' ,
'gridcell' , 'group' , 'heading' , 'img' , 'insertion' , 'link' , 'list' , 'listbox' , 'listitem' , 'log' , 'main' , 'mark' , 'marquee' , 'math' , 'meter' , 'menu' ,
'menubar' , 'menuitem' , 'menuitemcheckbox' , 'menuitemradio' , 'navigation' , 'none' , 'note' , 'option' , 'paragraph' , 'presentation' , 'progressbar' , 'radio' , 'radiogroup' ,
'region' , 'row' , 'rowgroup' , 'rowheader' , 'scrollbar' , 'search' , 'searchbox' , 'separator' , 'slider' ,
'spinbutton' , 'status' , 'strong' , 'subscript' , 'superscript' , 'switch' , 'tab' , 'table' , 'tablist' , 'tabpanel' , 'term' , 'textbox' , 'time' , 'timer' ,
'toolbar' , 'tooltip' , 'tree' , 'treegrid' , 'treeitem' ] ;
2022-03-21 17:26:45 -07:00
2024-10-18 20:18:18 -07:00
function getExplicitAriaRole ( element : Element ) : AriaRole | null {
2022-03-21 17:26:45 -07:00
// https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
2022-03-28 09:24:58 -07:00
const roles = ( element . getAttribute ( 'role' ) || '' ) . split ( ' ' ) . map ( role = > role . trim ( ) ) ;
2024-10-18 20:18:18 -07:00
return roles . find ( role = > validRoles . includes ( role as any ) ) as AriaRole || null ;
2022-03-21 17:26:45 -07:00
}
2024-04-18 08:53:31 -07:00
function hasPresentationConflictResolution ( element : Element , role : string | null ) {
2022-03-21 17:26:45 -07:00
// https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none
2024-04-18 08:53:31 -07:00
return hasGlobalAriaAttribute ( element , role ) || isFocusable ( element ) ;
2022-03-21 17:26:45 -07:00
}
2024-10-18 20:18:18 -07:00
export function getAriaRole ( element : Element ) : AriaRole | null {
2022-03-21 17:26:45 -07:00
const explicitRole = getExplicitAriaRole ( element ) ;
if ( ! explicitRole )
return getImplicitAriaRole ( element ) ;
2024-04-18 08:53:31 -07:00
if ( explicitRole === 'none' || explicitRole === 'presentation' ) {
const implicitRole = getImplicitAriaRole ( element ) ;
if ( hasPresentationConflictResolution ( element , implicitRole ) )
return implicitRole ;
}
2022-03-21 17:26:45 -07:00
return explicitRole ;
}
function getAriaBoolean ( attr : string | null ) {
return attr === null ? undefined : attr . toLowerCase ( ) === 'true' ;
}
2024-10-14 14:07:19 -07:00
export function isElementIgnoredForAria ( element : Element ) {
2024-08-20 09:02:23 -07:00
return [ 'STYLE' , 'SCRIPT' , 'NOSCRIPT' , 'TEMPLATE' ] . includes ( elementSafeTagName ( element ) ) ;
}
2022-03-28 09:24:58 -07:00
// https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion, but including "none" and "presentation" roles
2023-03-29 17:08:05 -07:00
// Not implemented:
// `Any descendants of elements that have the characteristic "Children Presentational: True"`
2022-03-28 09:24:58 -07:00
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
2023-06-16 11:39:39 -07:00
export function isElementHiddenForAria ( element : Element ) : boolean {
2024-08-20 09:02:23 -07:00
if ( isElementIgnoredForAria ( element ) )
2022-03-21 17:26:45 -07:00
return true ;
2023-06-08 16:00:48 -07:00
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 ) {
2023-06-16 11:39:39 -07:00
if ( child . nodeType === 1 /* Node.ELEMENT_NODE */ && ! isElementHiddenForAria ( child as Element ) )
2023-06-08 16:00:48 -07:00
return false ;
if ( child . nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode ( child as Text ) )
return false ;
}
return true ;
}
2023-02-07 15:10:18 -08:00
// Note: <option> inside <select> are not affected by visibility or content-visibility.
// Same goes for <slot>.
const isOptionInsideSelect = element . nodeName === 'OPTION' && ! ! element . closest ( 'select' ) ;
2023-06-08 16:00:48 -07:00
if ( ! isOptionInsideSelect && ! isSlot && ! isElementStyleVisibilityVisible ( element , style ) )
2022-03-21 17:26:45 -07:00
return true ;
2023-06-16 11:39:39 -07:00
return belongsToDisplayNoneOrAriaHiddenOrNonSlotted ( element ) ;
2022-03-28 09:24:58 -07:00
}
2022-03-21 17:26:45 -07:00
2023-06-16 11:39:39 -07:00
function belongsToDisplayNoneOrAriaHiddenOrNonSlotted ( element : Element ) : boolean {
let hidden = cacheIsHidden ? . get ( element ) ;
if ( hidden === undefined ) {
hidden = false ;
2023-03-29 17:08:05 -07:00
// When parent has a shadow root, all light dom children must be assigned to a slot,
// otherwise they are not rendered and considered hidden for aria.
// Note: we can remove this logic once WebKit supports `Element.checkVisibility`.
if ( element . parentElement && element . parentElement . shadowRoot && ! element . assignedSlot )
hidden = true ;
// display:none and aria-hidden=true are considered hidden for aria.
if ( ! hidden ) {
const style = getElementComputedStyle ( element ) ;
hidden = ! style || style . display === 'none' || getAriaBoolean ( element . getAttribute ( 'aria-hidden' ) ) === true ;
}
// Check recursively.
2022-03-28 09:24:58 -07:00
if ( ! hidden ) {
const parent = parentElementOrShadowHost ( element ) ;
if ( parent )
2023-06-16 11:39:39 -07:00
hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted ( parent ) ;
2022-03-21 17:26:45 -07:00
}
2023-06-16 11:39:39 -07:00
cacheIsHidden ? . set ( element , hidden ) ;
2022-03-21 17:26:45 -07:00
}
2023-06-16 11:39:39 -07:00
return hidden ;
2022-03-21 17:26:45 -07:00
}
function getIdRefs ( element : Element , ref : string | null ) : Element [ ] {
if ( ! ref )
return [ ] ;
const root = enclosingShadowRootOrDocument ( element ) ;
if ( ! root )
return [ ] ;
try {
const ids = ref . split ( ' ' ) . filter ( id = > ! ! id ) ;
2025-03-25 13:49:28 +00:00
const result : Element [ ] = [ ] ;
2022-03-21 17:26:45 -07:00
for ( const id of ids ) {
// https://www.w3.org/TR/wai-aria-1.2/#mapping_additional_relations_error_processing
// "If more than one element has the same ID, the user agent SHOULD use the first element found with the given ID"
const firstElement = root . querySelector ( '#' + CSS . escape ( id ) ) ;
2025-03-25 13:49:28 +00:00
if ( firstElement && ! result . includes ( firstElement ) )
result . push ( firstElement ) ;
2022-03-21 17:26:45 -07:00
}
2025-03-25 13:49:28 +00:00
return result ;
2022-03-21 17:26:45 -07:00
} catch ( e ) {
return [ ] ;
}
}
2024-04-19 12:49:49 -07:00
function trimFlatString ( s : string ) : string {
2022-03-21 17:26:45 -07:00
// "Flat string" at https://w3c.github.io/accname/#terminology
2024-04-19 12:49:49 -07:00
return s . trim ( ) ;
}
function asFlatString ( s : string ) : string {
// "Flat string" at https://w3c.github.io/accname/#terminology
// Note that non-breaking spaces are preserved.
2025-02-25 16:54:02 +00:00
return s . split ( '\u00A0' ) . map ( chunk = > chunk . replace ( /\r\n/g , '\n' ) . replace ( /[\u200b\u00ad]/g , '' ) . replace ( /\s\s*/g , ' ' ) ) . join ( '\u00A0' ) . trim ( ) ;
2022-03-21 17:26:45 -07:00
}
function queryInAriaOwned ( element : Element , selector : string ) : Element [ ] {
const result = [ . . . element . querySelectorAll ( selector ) ] ;
for ( const owned of getIdRefs ( element , element . getAttribute ( 'aria-owns' ) ) ) {
if ( owned . matches ( selector ) )
result . push ( owned ) ;
result . push ( . . . owned . querySelectorAll ( selector ) ) ;
}
return result ;
}
2025-05-08 17:42:13 +00:00
export function getCSSContent ( element : Element , pseudo ? : '::before' | '::after' ) {
// Relevant spec: 2.6.2 from https://w3c.github.io/accname/#computation-steps.
// Additional considerations: https://github.com/w3c/accname/issues/204.
const cache = pseudo === '::before' ? cachePseudoContentBefore : ( pseudo === '::after' ? cachePseudoContentAfter : cachePseudoContent ) ;
2024-01-23 10:09:23 -08:00
if ( cache ? . has ( element ) )
2025-05-08 17:42:13 +00:00
return cache ? . get ( element ) ;
2024-01-23 10:09:23 -08:00
2025-05-08 17:42:13 +00:00
const style = getElementComputedStyle ( element , pseudo ) ;
let content : string | undefined ;
if ( style && style . display !== 'none' && style . visibility !== 'hidden' ) {
// Note: all browsers ignore display:none and visibility:hidden pseudos.
content = parseCSSContentPropertyAsString ( element , style . content , ! ! pseudo ) ;
2025-01-28 14:37:04 -08:00
}
2025-05-08 17:42:13 +00:00
if ( pseudo && content !== undefined ) {
2022-03-21 17:26:45 -07:00
// SPEC DIFFERENCE.
// Spec says "CSS textual content, without a space", but we account for display
// to pass "name_file-label-inline-block-styles-manual.html"
2025-05-08 17:42:13 +00:00
const display = style ? . display || 'inline' ;
2022-03-21 17:26:45 -07:00
if ( display !== 'inline' )
2025-05-08 17:42:13 +00:00
content = ' ' + content + ' ' ;
}
if ( cache )
cache . set ( element , content ) ;
return content ;
}
function parseCSSContentPropertyAsString ( element : Element , content : string , isPseudo : boolean ) : string | undefined {
// Welcome to the mini CSS parser!
// It aims to support the following syntax and any subset of it:
// content: "one" attr(...) "two" "three" / "alt" attr(...) "more alt"
// See https://developer.mozilla.org/en-US/docs/Web/CSS/content for more details.
if ( ! content || content === 'none' || content === 'normal' ) {
// Common fast path.
return ;
}
try {
let tokens = css . tokenize ( content ) . filter ( token = > ! ( token instanceof css . WhitespaceToken ) ) ;
const delimIndex = tokens . findIndex ( token = > token instanceof css . DelimToken && token . value === '/' ) ;
if ( delimIndex !== - 1 ) {
// Use the alternative text part when exists.
// content: ... / "alternative text"
tokens = tokens . slice ( delimIndex + 1 ) ;
} else if ( ! isPseudo ) {
// For non-pseudo elements, the only valid content is a url() or various gradients.
// Therefore, we follow Chrome and only consider the alternative text.
// Firefox, on the other hand, calculates accessible name to be empty.
return ;
}
const accumulated : string [ ] = [ ] ;
let index = 0 ;
while ( index < tokens . length ) {
if ( tokens [ index ] instanceof css . StringToken ) {
// content: "some text"
accumulated . push ( tokens [ index ] . value as string ) ;
index ++ ;
} else if ( index + 2 < tokens . length && tokens [ index ] instanceof css . FunctionToken && tokens [ index ] . value === 'attr' && tokens [ index + 1 ] instanceof css . IdentToken && tokens [ index + 2 ] instanceof css . CloseParenToken ) {
// content: attr(...)
// Firefox does not resolve attribute accessors in content, so we do it manually.
const attrName = tokens [ index + 1 ] . value as string ;
accumulated . push ( element . getAttribute ( attrName ) || '' ) ;
index += 3 ;
} else {
// Failed to parse the content, so ignore it.
return ;
}
}
return accumulated . join ( '' ) ;
} catch {
2022-03-21 17:26:45 -07:00
}
}
2022-12-14 13:51:05 -08:00
export function getAriaLabelledByElements ( element : Element ) : Element [ ] | null {
const ref = element . getAttribute ( 'aria-labelledby' ) ;
if ( ref === null )
return null ;
2024-11-19 11:56:16 +00:00
const refs = getIdRefs ( element , ref ) ;
// step 2b:
// "if the current node has an aria-labelledby attribute that contains at least one valid IDREF"
// Therefore, if none of the refs match an element, we consider aria-labelledby to be missing.
return refs . length ? refs : null ;
2022-12-14 13:51:05 -08:00
}
2022-12-27 09:06:46 -08:00
function allowsNameFromContent ( role : string , targetDescendant : boolean ) {
// SPEC: https://w3c.github.io/aria/#namefromcontent
//
// Note: there is a spec proposal https://github.com/w3c/aria/issues/1821 that
// is roughly aligned with what Chrome/Firefox do, and we follow that.
//
// See chromium implementation here:
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/accessibility/ax_object.cc;l=6338;drc=3decef66bc4c08b142a19db9628e9efe68973e64;bpv=0;bpt=1
const alwaysAllowsNameFromContent = [ 'button' , 'cell' , 'checkbox' , 'columnheader' , 'gridcell' , 'heading' , 'link' , 'menuitem' , 'menuitemcheckbox' , 'menuitemradio' , 'option' , 'radio' , 'row' , 'rowheader' , 'switch' , 'tab' , 'tooltip' , 'treeitem' ] . includes ( role ) ;
const descendantAllowsNameFromContent = targetDescendant && [ '' , 'caption' , 'code' , 'contentinfo' , 'definition' , 'deletion' , 'emphasis' , 'insertion' , 'list' , 'listitem' , 'mark' , 'none' , 'paragraph' , 'presentation' , 'region' , 'row' , 'rowgroup' , 'section' , 'strong' , 'subscript' , 'superscript' , 'table' , 'term' , 'time' ] . includes ( role ) ;
return alwaysAllowsNameFromContent || descendantAllowsNameFromContent ;
}
2025-04-10 08:30:39 +00:00
export function getElementAccessibleName ( element : Element , includeHidden : boolean ) : string {
2023-06-16 11:39:39 -07:00
const cache = ( includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName ) ;
let accessibleName = cache ? . get ( element ) ;
if ( accessibleName === undefined ) {
// https://w3c.github.io/accname/#computation-steps
accessibleName = '' ;
// step 1.
// https://w3c.github.io/aria/#namefromprohibited
const elementProhibitsNaming = [ 'caption' , 'code' , 'definition' , 'deletion' , 'emphasis' , 'generic' , 'insertion' , 'mark' , 'paragraph' , 'presentation' , 'strong' , 'subscript' , 'suggestion' , 'superscript' , 'term' , 'time' ] . includes ( getAriaRole ( element ) || '' ) ;
if ( ! elementProhibitsNaming ) {
// step 2.
2024-04-19 12:49:49 -07:00
accessibleName = asFlatString ( getTextAlternativeInternal ( element , {
2023-06-16 11:39:39 -07:00
includeHidden ,
2025-04-10 08:30:39 +00:00
visitedElements : new Set ( ) ,
2023-06-16 11:39:39 -07:00
embeddedInTargetElement : 'self' ,
} ) ) ;
}
2022-03-21 17:26:45 -07:00
2023-06-16 11:39:39 -07:00
cache ? . set ( element , accessibleName ) ;
}
2022-03-21 17:26:45 -07:00
return accessibleName ;
}
2025-04-10 08:30:39 +00:00
export function getElementAccessibleDescription ( element : Element , includeHidden : boolean ) : string {
2024-04-19 12:49:49 -07:00
const cache = ( includeHidden ? cacheAccessibleDescriptionHidden : cacheAccessibleDescription ) ;
let accessibleDescription = cache ? . get ( element ) ;
if ( accessibleDescription === undefined ) {
// https://w3c.github.io/accname/#mapping_additional_nd_description
// https://www.w3.org/TR/html-aam-1.0/#accdesc-computation
accessibleDescription = '' ;
if ( element . hasAttribute ( 'aria-describedby' ) ) {
// precedence 1
const describedBy = getIdRefs ( element , element . getAttribute ( 'aria-describedby' ) ) ;
accessibleDescription = asFlatString ( describedBy . map ( ref = > getTextAlternativeInternal ( ref , {
includeHidden ,
2025-04-10 08:30:39 +00:00
visitedElements : new Set ( ) ,
2024-04-19 12:49:49 -07:00
embeddedInDescribedBy : { element : ref , hidden : isElementHiddenForAria ( ref ) } ,
} ) ) . join ( ' ' ) ) ;
} else if ( element . hasAttribute ( 'aria-description' ) ) {
// precedence 2
accessibleDescription = asFlatString ( element . getAttribute ( 'aria-description' ) || '' ) ;
} else {
// TODO: handle precedence 3 - html-aam-specific cases like table>caption.
// https://www.w3.org/TR/html-aam-1.0/#accdesc-computation
// precedence 4
accessibleDescription = asFlatString ( element . getAttribute ( 'title' ) || '' ) ;
}
cache ? . set ( element , accessibleDescription ) ;
}
return accessibleDescription ;
}
2024-12-27 18:54:16 +09:00
function getAriaInvalid ( element : Element ) : 'false' | 'true' | 'grammar' | 'spelling' {
2025-05-27 15:46:47 -07:00
// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
// This state is being deprecated as a global state in ARIA 1.2.
// In future versions it will only be allowed on roles where it is specifically supported.
2024-12-27 18:54:16 +09:00
const ariaInvalid = element . getAttribute ( 'aria-invalid' ) ;
if ( ! ariaInvalid || ariaInvalid . trim ( ) === '' || ariaInvalid . toLocaleLowerCase ( ) === 'false' )
return 'false' ;
if ( ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling' )
return ariaInvalid ;
return 'true' ;
}
function getValidityInvalid ( element : Element ) {
if ( 'validity' in element ) {
const validity = element . validity as ValidityState | undefined ;
return validity ? . valid === false ;
}
return false ;
}
2025-04-10 08:30:39 +00:00
export function getElementAccessibleErrorMessage ( element : Element ) : string {
2024-12-27 18:54:16 +09:00
// SPEC: https://w3c.github.io/aria/#aria-errormessage
//
// TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
const cache = cacheAccessibleErrorMessage ;
let accessibleErrorMessage = cacheAccessibleErrorMessage ? . get ( element ) ;
if ( accessibleErrorMessage === undefined ) {
accessibleErrorMessage = '' ;
const isAriaInvalid = getAriaInvalid ( element ) !== 'false' ;
const isValidityInvalid = getValidityInvalid ( element ) ;
if ( isAriaInvalid || isValidityInvalid ) {
const errorMessageId = element . getAttribute ( 'aria-errormessage' ) ;
const errorMessages = getIdRefs ( element , errorMessageId ) ;
// Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules.
// Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
const parts = errorMessages . map ( errorMessage = > asFlatString (
getTextAlternativeInternal ( errorMessage , {
2025-04-10 08:30:39 +00:00
visitedElements : new Set ( ) ,
2024-12-27 18:54:16 +09:00
embeddedInDescribedBy : { element : errorMessage , hidden : isElementHiddenForAria ( errorMessage ) } ,
} )
) ) ;
accessibleErrorMessage = parts . join ( ' ' ) . trim ( ) ;
}
cache ? . set ( element , accessibleErrorMessage ) ;
}
return accessibleErrorMessage ;
}
2022-03-21 17:26:45 -07:00
type AccessibleNameOptions = {
2025-04-10 08:30:39 +00:00
visitedElements : Set < Element > ,
2024-10-17 17:06:18 -07:00
includeHidden? : boolean ,
embeddedInDescribedBy ? : { element : Element , hidden : boolean } ,
embeddedInLabelledBy ? : { element : Element , hidden : boolean } ,
embeddedInLabel ? : { element : Element , hidden : boolean } ,
embeddedInNativeTextAlternative ? : { element : Element , hidden : boolean } ,
embeddedInTargetElement ? : 'self' | 'descendant' ,
2022-03-21 17:26:45 -07:00
} ;
2024-04-19 12:49:49 -07:00
function getTextAlternativeInternal ( element : Element , options : AccessibleNameOptions ) : string {
2022-03-21 17:26:45 -07:00
if ( options . visitedElements . has ( element ) )
return '' ;
const childOptions : AccessibleNameOptions = {
. . . options ,
embeddedInTargetElement : options.embeddedInTargetElement === 'self' ? 'descendant' : options . embeddedInTargetElement ,
} ;
2024-04-17 11:22:09 -07:00
// step 2a. Hidden Not Referenced: If the current node is hidden and is:
// Not part of an aria-labelledby or aria-describedby traversal, where the node directly referenced by that relation was hidden.
// Nor part of a native host language text alternative element (e.g. label in HTML) or attribute traversal, where the root of that traversal was hidden.
2024-08-20 09:02:23 -07:00
if ( ! options . includeHidden ) {
const isEmbeddedInHiddenReferenceTraversal =
! ! options . embeddedInLabelledBy ? . hidden ||
! ! options . embeddedInDescribedBy ? . hidden ||
! ! options . embeddedInNativeTextAlternative ? . hidden ||
! ! options . embeddedInLabel ? . hidden ;
if ( isElementIgnoredForAria ( element ) ||
( ! isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria ( element ) ) ) {
options . visitedElements . add ( element ) ;
return '' ;
}
2022-03-21 17:26:45 -07:00
}
2023-05-03 16:09:08 -07:00
const labelledBy = getAriaLabelledByElements ( element ) ;
2024-04-17 12:25:08 -07:00
// step 2b. LabelledBy:
// Otherwise, if the current node has an aria-labelledby attribute that contains
// at least one valid IDREF, and the current node is not already part of an ongoing
// aria-labelledby or aria-describedby traversal, process its IDREFs in the order they occur...
2024-04-17 11:22:09 -07:00
if ( ! options . embeddedInLabelledBy ) {
2024-04-19 12:49:49 -07:00
const accessibleName = ( labelledBy || [ ] ) . map ( ref = > getTextAlternativeInternal ( ref , {
2022-03-21 17:26:45 -07:00
. . . options ,
2024-04-17 11:22:09 -07:00
embeddedInLabelledBy : { element : ref , hidden : isElementHiddenForAria ( ref ) } ,
2024-04-19 12:49:49 -07:00
embeddedInDescribedBy : undefined ,
2024-10-17 17:06:18 -07:00
embeddedInTargetElement : undefined ,
2024-04-17 11:22:09 -07:00
embeddedInLabel : undefined ,
embeddedInNativeTextAlternative : undefined ,
2022-03-21 17:26:45 -07:00
} ) ) . join ( ' ' ) ;
if ( accessibleName )
return accessibleName ;
}
const role = getAriaRole ( element ) || '' ;
2024-05-02 09:42:19 -07:00
const tagName = elementSafeTagName ( element ) ;
2022-03-21 17:26:45 -07:00
2024-04-17 12:25:08 -07:00
// step 2c:
// if the current node is a control embedded within the label (e.g. any element directly referenced by aria-labelledby) for another widget...
//
// also step 2d "skip to rule Embedded Control" section:
// If traversal of the current node is due to recursion and the current node is an embedded control...
// Note this is not strictly by the spec, because spec only applies this logic when "aria-label" is present.
// However, browsers and and wpt test name_heading-combobox-focusable-alternative-manual.html follow this behavior,
// and there is an issue filed for this: https://github.com/w3c/accname/issues/64
if ( ! ! options . embeddedInLabel || ! ! options . embeddedInLabelledBy || options . embeddedInTargetElement === 'descendant' ) {
2022-03-21 17:26:45 -07:00
const isOwnLabel = [ . . . ( element as ( HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement ) ) . labels || [ ] ] . includes ( element as any ) ;
2023-05-03 16:09:08 -07:00
const isOwnLabelledBy = ( labelledBy || [ ] ) . includes ( element ) ;
2022-03-21 17:26:45 -07:00
if ( ! isOwnLabel && ! isOwnLabelledBy ) {
if ( role === 'textbox' ) {
options . visitedElements . add ( element ) ;
2024-05-02 09:42:19 -07:00
if ( tagName === 'INPUT' || tagName === 'TEXTAREA' )
2022-03-21 17:26:45 -07:00
return ( element as HTMLInputElement | HTMLTextAreaElement ) . value ;
return element . textContent || '' ;
}
if ( [ 'combobox' , 'listbox' ] . includes ( role ) ) {
options . visitedElements . add ( element ) ;
let selectedOptions : Element [ ] ;
2024-05-02 09:42:19 -07:00
if ( tagName === 'SELECT' ) {
2022-03-21 17:26:45 -07:00
selectedOptions = [ . . . ( element as HTMLSelectElement ) . selectedOptions ] ;
if ( ! selectedOptions . length && ( element as HTMLSelectElement ) . options . length )
selectedOptions . push ( ( element as HTMLSelectElement ) . options [ 0 ] ) ;
} else {
const listbox = role === 'combobox' ? queryInAriaOwned ( element , '*' ) . find ( e = > getAriaRole ( e ) === 'listbox' ) : element ;
selectedOptions = listbox ? queryInAriaOwned ( listbox , '[aria-selected="true"]' ) . filter ( e = > getAriaRole ( e ) === 'option' ) : [ ] ;
}
2024-05-02 09:42:19 -07:00
if ( ! selectedOptions . length && tagName === 'INPUT' ) {
2024-04-17 12:25:08 -07:00
// SPEC DIFFERENCE:
// This fallback is not explicitly mentioned in the spec, but all browsers and
// wpt test name_heading-combobox-focusable-alternative-manual.html do this.
return ( element as HTMLInputElement ) . value ;
}
2024-04-19 12:49:49 -07:00
return selectedOptions . map ( option = > getTextAlternativeInternal ( option , childOptions ) ) . join ( ' ' ) ;
2022-03-21 17:26:45 -07:00
}
if ( [ 'progressbar' , 'scrollbar' , 'slider' , 'spinbutton' , 'meter' ] . includes ( role ) ) {
options . visitedElements . add ( element ) ;
if ( element . hasAttribute ( 'aria-valuetext' ) )
return element . getAttribute ( 'aria-valuetext' ) || '' ;
if ( element . hasAttribute ( 'aria-valuenow' ) )
return element . getAttribute ( 'aria-valuenow' ) || '' ;
return element . getAttribute ( 'value' ) || '' ;
}
if ( [ 'menu' ] . includes ( role ) ) {
// https://github.com/w3c/accname/issues/67#issuecomment-553196887
options . visitedElements . add ( element ) ;
return '' ;
}
}
}
// step 2d.
const ariaLabel = element . getAttribute ( 'aria-label' ) || '' ;
2024-04-19 12:49:49 -07:00
if ( trimFlatString ( ariaLabel ) ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
return ariaLabel ;
}
// step 2e.
if ( ! [ 'presentation' , 'none' ] . includes ( role ) ) {
2023-03-31 14:17:18 -07:00
// https://w3c.github.io/html-aam/#input-type-button-input-type-submit-and-input-type-reset-accessible-name-computation
2023-05-03 16:09:08 -07:00
//
// SPEC DIFFERENCE.
// Spec says to ignore this when aria-labelledby is defined.
// WebKit follows the spec, while Chromium and Firefox do not.
// We align with Chromium and Firefox here.
2024-05-02 09:42:19 -07:00
if ( tagName === 'INPUT' && [ 'button' , 'submit' , 'reset' ] . includes ( ( element as HTMLInputElement ) . type ) ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
const value = ( element as HTMLInputElement ) . value || '' ;
2024-04-19 12:49:49 -07:00
if ( trimFlatString ( value ) )
2022-03-21 17:26:45 -07:00
return value ;
if ( ( element as HTMLInputElement ) . type === 'submit' )
return 'Submit' ;
if ( ( element as HTMLInputElement ) . type === 'reset' )
return 'Reset' ;
const title = element . getAttribute ( 'title' ) || '' ;
return title ;
}
2025-04-08 08:02:19 +00:00
// SPEC DIFFERENCE.
// There is no spec for this, but Chromium/WebKit do "Choose File" so we follow that.
// All browsers respect labels, aria-labelledby and aria-label.
// No browsers respect the title attribute, although w3c accname tests disagree. We follow browsers.
if ( ! getGlobalOptions ( ) . inputFileRoleTextbox && tagName === 'INPUT' && ( element as HTMLInputElement ) . type === 'file' ) {
options . visitedElements . add ( element ) ;
const labels = ( element as HTMLInputElement ) . labels || [ ] ;
if ( labels . length && ! options . embeddedInLabelledBy )
return getAccessibleNameFromAssociatedLabels ( labels , options ) ;
return 'Choose File' ;
}
2023-03-31 14:17:18 -07:00
// https://w3c.github.io/html-aam/#input-type-image-accessible-name-computation
2023-05-03 16:09:08 -07:00
//
// SPEC DIFFERENCE.
// Spec says to ignore this when aria-labelledby is defined, but all browsers take it into account.
2024-05-02 09:42:19 -07:00
if ( tagName === 'INPUT' && ( element as HTMLInputElement ) . type === 'image' ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
const labels = ( element as HTMLInputElement ) . labels || [ ] ;
2024-04-17 11:22:09 -07:00
if ( labels . length && ! options . embeddedInLabelledBy )
2023-10-03 13:01:13 -07:00
return getAccessibleNameFromAssociatedLabels ( labels , options ) ;
2023-05-03 16:09:08 -07:00
const alt = element . getAttribute ( 'alt' ) || '' ;
2024-04-19 12:49:49 -07:00
if ( trimFlatString ( alt ) )
2023-05-03 16:09:08 -07:00
return alt ;
2022-03-21 17:26:45 -07:00
const title = element . getAttribute ( 'title' ) || '' ;
2024-04-19 12:49:49 -07:00
if ( trimFlatString ( title ) )
2022-03-21 17:26:45 -07:00
return title ;
2022-03-29 11:59:44 -07:00
// SPEC DIFFERENCE.
2024-05-08 20:40:03 +02:00
// Spec says return localized "Submit Query", but browsers and axe-core insist on "Submit".
2022-03-29 11:59:44 -07:00
return 'Submit' ;
2022-03-21 17:26:45 -07:00
}
2023-03-31 14:17:18 -07:00
// https://w3c.github.io/html-aam/#button-element-accessible-name-computation
2024-05-02 09:42:19 -07:00
if ( ! labelledBy && tagName === 'BUTTON' ) {
2023-03-31 14:17:18 -07:00
options . visitedElements . add ( element ) ;
const labels = ( element as HTMLButtonElement ) . labels || [ ] ;
2023-10-03 13:01:13 -07:00
if ( labels . length )
return getAccessibleNameFromAssociatedLabels ( labels , options ) ;
2023-03-31 14:17:18 -07:00
// From here, fallthrough to step 2f.
}
2023-10-03 13:01:13 -07:00
// https://w3c.github.io/html-aam/#output-element-accessible-name-computation
2024-05-02 09:42:19 -07:00
if ( ! labelledBy && tagName === 'OUTPUT' ) {
2023-10-03 13:01:13 -07:00
options . visitedElements . add ( element ) ;
const labels = ( element as HTMLOutputElement ) . labels || [ ] ;
if ( labels . length )
return getAccessibleNameFromAssociatedLabels ( labels , options ) ;
return element . getAttribute ( 'title' ) || '' ;
}
2023-03-31 14:17:18 -07:00
// https://w3c.github.io/html-aam/#input-type-text-input-type-password-input-type-number-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-element-accessible-name-computation
// https://w3c.github.io/html-aam/#other-form-elements-accessible-name-computation
2022-03-21 17:26:45 -07:00
// For "other form elements", we count select and any other input.
2023-05-03 16:09:08 -07:00
//
// Note: WebKit does not follow the spec and uses placeholder when aria-labelledby is present.
2024-05-02 09:42:19 -07:00
if ( ! labelledBy && ( tagName === 'TEXTAREA' || tagName === 'SELECT' || tagName === 'INPUT' ) ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
const labels = ( element as ( HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement ) ) . labels || [ ] ;
2023-10-03 13:01:13 -07:00
if ( labels . length )
return getAccessibleNameFromAssociatedLabels ( labels , options ) ;
2022-03-21 17:26:45 -07:00
2024-05-02 09:42:19 -07:00
const usePlaceholder = ( tagName === 'INPUT' && [ 'text' , 'password' , 'search' , 'tel' , 'email' , 'url' ] . includes ( ( element as HTMLInputElement ) . type ) ) || tagName === 'TEXTAREA' ;
2022-03-21 17:26:45 -07:00
const placeholder = element . getAttribute ( 'placeholder' ) || '' ;
const title = element . getAttribute ( 'title' ) || '' ;
if ( ! usePlaceholder || title )
return title ;
return placeholder ;
}
// https://w3c.github.io/html-aam/#fieldset-and-legend-elements
2024-05-02 09:42:19 -07:00
if ( ! labelledBy && tagName === 'FIELDSET' ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
for ( let child = element . firstElementChild ; child ; child = child . nextElementSibling ) {
2024-05-02 09:42:19 -07:00
if ( elementSafeTagName ( child ) === 'LEGEND' ) {
2024-04-19 12:49:49 -07:00
return getTextAlternativeInternal ( child , {
2022-03-21 17:26:45 -07:00
. . . childOptions ,
2024-04-17 11:22:09 -07:00
embeddedInNativeTextAlternative : { element : child , hidden : isElementHiddenForAria ( child ) } ,
2022-03-21 17:26:45 -07:00
} ) ;
}
}
const title = element . getAttribute ( 'title' ) || '' ;
return title ;
}
// https://w3c.github.io/html-aam/#figure-and-figcaption-elements
2024-05-02 09:42:19 -07:00
if ( ! labelledBy && tagName === 'FIGURE' ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
for ( let child = element . firstElementChild ; child ; child = child . nextElementSibling ) {
2024-05-02 09:42:19 -07:00
if ( elementSafeTagName ( child ) === 'FIGCAPTION' ) {
2024-04-19 12:49:49 -07:00
return getTextAlternativeInternal ( child , {
2022-03-21 17:26:45 -07:00
. . . childOptions ,
2024-04-17 11:22:09 -07:00
embeddedInNativeTextAlternative : { element : child , hidden : isElementHiddenForAria ( child ) } ,
2022-03-21 17:26:45 -07:00
} ) ;
}
}
const title = element . getAttribute ( 'title' ) || '' ;
return title ;
}
// https://w3c.github.io/html-aam/#img-element
2023-05-03 16:09:08 -07:00
//
// SPEC DIFFERENCE.
// Spec says to ignore this when aria-labelledby is defined, but all browsers take it into account.
2024-05-02 09:42:19 -07:00
if ( tagName === 'IMG' ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
const alt = element . getAttribute ( 'alt' ) || '' ;
2024-04-19 12:49:49 -07:00
if ( trimFlatString ( alt ) )
2022-03-21 17:26:45 -07:00
return alt ;
const title = element . getAttribute ( 'title' ) || '' ;
return title ;
}
// https://w3c.github.io/html-aam/#table-element
2024-05-02 09:42:19 -07:00
if ( tagName === 'TABLE' ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
for ( let child = element . firstElementChild ; child ; child = child . nextElementSibling ) {
2024-05-02 09:42:19 -07:00
if ( elementSafeTagName ( child ) === 'CAPTION' ) {
2024-04-19 12:49:49 -07:00
return getTextAlternativeInternal ( child , {
2022-03-21 17:26:45 -07:00
. . . childOptions ,
2024-04-17 11:22:09 -07:00
embeddedInNativeTextAlternative : { element : child , hidden : isElementHiddenForAria ( child ) } ,
2022-03-21 17:26:45 -07:00
} ) ;
}
}
// SPEC DIFFERENCE.
2022-03-29 11:59:44 -07:00
// Spec does not say a word about <table summary="...">, but all browsers actually support it.
const summary = element . getAttribute ( 'summary' ) || '' ;
if ( summary )
return summary ;
// SPEC DIFFERENCE.
2022-03-21 17:26:45 -07:00
// 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
2024-05-02 09:42:19 -07:00
if ( tagName === 'AREA' ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
const alt = element . getAttribute ( 'alt' ) || '' ;
2024-04-19 12:49:49 -07:00
if ( trimFlatString ( alt ) )
2022-03-21 17:26:45 -07:00
return alt ;
const title = element . getAttribute ( 'title' ) || '' ;
return title ;
}
2023-03-28 15:52:16 -07:00
// https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd
2024-05-02 09:42:19 -07:00
if ( tagName === 'SVG' || ( element as SVGElement ) . ownerSVGElement ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
for ( let child = element . firstElementChild ; child ; child = child . nextElementSibling ) {
2024-05-02 09:42:19 -07:00
if ( elementSafeTagName ( child ) === 'TITLE' && ( child as SVGElement ) . ownerSVGElement ) {
2024-04-19 12:49:49 -07:00
return getTextAlternativeInternal ( child , {
2022-03-21 17:26:45 -07:00
. . . childOptions ,
2024-04-17 11:22:09 -07:00
embeddedInLabelledBy : { element : child , hidden : isElementHiddenForAria ( child ) } ,
2022-03-21 17:26:45 -07:00
} ) ;
}
}
}
2024-05-02 09:42:19 -07:00
if ( ( element as SVGElement ) . ownerSVGElement && tagName === 'A' ) {
2023-03-28 15:52:16 -07:00
const title = element . getAttribute ( 'xlink:title' ) || '' ;
2024-04-19 12:49:49 -07:00
if ( trimFlatString ( title ) ) {
2023-03-28 15:52:16 -07:00
options . visitedElements . add ( element ) ;
return title ;
}
}
2022-03-21 17:26:45 -07:00
}
2024-04-19 12:49:49 -07:00
// See https://w3c.github.io/html-aam/#summary-element-accessible-name-computation for "summary"-specific check.
2024-05-02 09:42:19 -07:00
const shouldNameFromContentForSummary = tagName === 'SUMMARY' && ! [ 'presentation' , 'none' ] . includes ( role ) ;
2024-04-19 12:49:49 -07:00
2022-03-21 17:26:45 -07:00
// step 2f + step 2h.
2022-12-27 09:06:46 -08:00
if ( allowsNameFromContent ( role , options . embeddedInTargetElement === 'descendant' ) ||
2024-04-19 12:49:49 -07:00
shouldNameFromContentForSummary ||
! ! options . embeddedInLabelledBy || ! ! options . embeddedInDescribedBy ||
! ! options . embeddedInLabel || ! ! options . embeddedInNativeTextAlternative ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
2025-04-10 08:30:39 +00:00
const accessibleName = innerAccumulatedElementText ( element , childOptions ) ;
2024-04-19 12:49:49 -07:00
// Spec says "Return the accumulated text if it is not the empty string". However, that is not really
// compatible with the real browser behavior and wpt tests, where an element with empty contents will fallback to the title.
// So we follow the spec everywhere except for the target element itself. This can probably be improved.
const maybeTrimmedAccessibleName = options . embeddedInTargetElement === 'self' ? trimFlatString ( accessibleName ) : accessibleName ;
if ( maybeTrimmedAccessibleName )
2022-03-21 17:26:45 -07:00
return accessibleName ;
}
// step 2i.
2024-05-02 09:42:19 -07:00
if ( ! [ 'presentation' , 'none' ] . includes ( role ) || tagName === 'IFRAME' ) {
2022-03-21 17:26:45 -07:00
options . visitedElements . add ( element ) ;
const title = element . getAttribute ( 'title' ) || '' ;
2024-04-19 12:49:49 -07:00
if ( trimFlatString ( title ) )
2022-03-21 17:26:45 -07:00
return title ;
}
options . visitedElements . add ( element ) ;
return '' ;
}
2022-03-28 09:24:58 -07:00
2025-04-10 08:30:39 +00:00
function innerAccumulatedElementText ( element : Element , options : AccessibleNameOptions ) : string {
2024-10-17 17:06:18 -07:00
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 = getTextAlternativeInternal ( node as Element , options ) ;
// 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, <br> 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 || '' ) ;
}
} ;
2025-05-08 17:42:13 +00:00
tokens . push ( getCSSContent ( element , '::before' ) || '' ) ;
const content = getCSSContent ( element ) ;
if ( content !== undefined ) {
// `content` CSS property replaces everything inside the element.
// I was not able to find any spec or description on how this interacts with accname,
// so this is a guess based on what browsers do.
tokens . push ( content ) ;
2024-10-17 17:06:18 -07:00
} else {
2025-05-08 17:42:13 +00:00
// step 2h.
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 )
2024-10-17 17:06:18 -07:00
visit ( child , true ) ;
2025-05-08 17:42:13 +00:00
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 ) ;
2024-10-17 17:06:18 -07:00
}
}
2025-05-08 17:42:13 +00:00
tokens . push ( getCSSContent ( element , '::after' ) || '' ) ;
2024-10-17 17:06:18 -07:00
return tokens . join ( '' ) ;
}
2022-03-28 09:24:58 -07:00
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
2024-05-02 09:42:19 -07:00
if ( elementSafeTagName ( element ) === 'OPTION' )
2022-03-28 09:24:58 -07:00
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' {
2023-02-10 18:56:45 -08:00
const result = getChecked ( element , true ) ;
2022-10-25 06:11:11 -07:00
return result === 'error' ? false : result ;
}
2025-01-09 18:18:15 -08:00
export function getCheckedAllowMixed ( element : Element ) : boolean | 'mixed' | 'error' {
return getChecked ( element , true ) ;
}
export function getCheckedWithoutMixed ( element : Element ) : boolean | 'error' {
const result = getChecked ( element , false ) ;
return result as boolean | 'error' ;
}
function getChecked ( element : Element , allowMixed : boolean ) : boolean | 'mixed' | 'error' {
2024-05-02 09:42:19 -07:00
const tagName = elementSafeTagName ( element ) ;
2022-03-28 09:24:58 -07:00
// 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
2024-05-02 09:42:19 -07:00
if ( allowMixed && tagName === 'INPUT' && ( element as HTMLInputElement ) . indeterminate )
2022-03-28 09:24:58 -07:00
return 'mixed' ;
2024-05-02 09:42:19 -07:00
if ( tagName === 'INPUT' && [ 'checkbox' , 'radio' ] . includes ( ( element as HTMLInputElement ) . type ) )
2022-03-28 09:24:58 -07:00
return ( element as HTMLInputElement ) . checked ;
if ( kAriaCheckedRoles . includes ( getAriaRole ( element ) || '' ) ) {
const checked = element . getAttribute ( 'aria-checked' ) ;
if ( checked === 'true' )
return true ;
2023-02-10 18:56:45 -08:00
if ( allowMixed && checked === 'mixed' )
2022-03-28 09:24:58 -07:00
return 'mixed' ;
2022-10-25 06:11:11 -07:00
return false ;
2022-03-28 09:24:58 -07:00
}
2022-10-25 06:11:11 -07:00
return 'error' ;
2022-03-28 09:24:58 -07:00
}
2024-11-22 11:40:43 +00:00
// https://w3c.github.io/aria/#aria-readonly
const kAriaReadonlyRoles = [ 'checkbox' , 'combobox' , 'grid' , 'gridcell' , 'listbox' , 'radiogroup' , 'slider' , 'spinbutton' , 'textbox' , 'columnheader' , 'rowheader' , 'searchbox' , 'switch' , 'treegrid' ] ;
export function getReadonly ( element : Element ) : boolean | 'error' {
const tagName = elementSafeTagName ( element ) ;
// 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 ( [ 'INPUT' , 'TEXTAREA' , 'SELECT' ] . includes ( tagName ) )
return element . hasAttribute ( 'readonly' ) ;
if ( kAriaReadonlyRoles . includes ( getAriaRole ( element ) || '' ) )
return element . getAttribute ( 'aria-readonly' ) === 'true' ;
if ( ( element as HTMLElement ) . isContentEditable )
return false ;
return 'error' ;
}
2022-03-28 09:24:58 -07:00
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' ] ;
2024-10-19 14:23:08 -07:00
export function getAriaExpanded ( element : Element ) : boolean | undefined {
2022-03-28 09:24:58 -07:00
// 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
2024-05-02 09:42:19 -07:00
if ( elementSafeTagName ( element ) === 'DETAILS' )
2022-03-28 09:24:58 -07:00
return ( element as HTMLDetailsElement ) . open ;
2022-11-21 14:13:51 -08:00
if ( kAriaExpandedRoles . includes ( getAriaRole ( element ) || '' ) ) {
const expanded = element . getAttribute ( 'aria-expanded' ) ;
if ( expanded === null )
2024-10-19 14:23:08 -07:00
return undefined ;
2022-11-21 14:13:51 -08:00
if ( expanded === 'true' )
return true ;
return false ;
}
2024-10-19 14:23:08 -07:00
return undefined ;
2022-03-28 09:24:58 -07:00
}
export const kAriaLevelRoles = [ 'heading' , 'listitem' , 'row' , 'treeitem' ] ;
2022-05-11 13:49:12 +01:00
export function getAriaLevel ( element : Element ) : number {
2022-03-28 09:24:58 -07:00
// 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
2024-05-02 09:42:19 -07:00
const native = { 'H1' : 1 , 'H2' : 2 , 'H3' : 3 , 'H4' : 4 , 'H5' : 5 , 'H6' : 6 } [ elementSafeTagName ( element ) ] ;
2022-03-28 09:24:58 -07:00
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
// Note that aria-disabled applies to all descendants, so we look up the hierarchy.
2024-04-18 08:53:31 -07:00
return isNativelyDisabled ( element ) || hasExplicitAriaDisabled ( element ) ;
}
function isNativelyDisabled ( element : Element ) {
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
2022-03-28 09:24:58 -07:00
const isNativeFormControl = [ 'BUTTON' , 'INPUT' , 'SELECT' , 'TEXTAREA' , 'OPTION' , 'OPTGROUP' ] . includes ( element . tagName ) ;
2024-04-18 08:53:31 -07:00
return isNativeFormControl && ( element . hasAttribute ( 'disabled' ) || belongsToDisabledFieldSet ( element ) ) ;
2022-03-28 09:24:58 -07:00
}
2025-03-06 11:46:53 -08:00
function belongsToDisabledFieldSet ( element : Element ) : boolean {
const fieldSetElement = element ? . closest ( 'FIELDSET[DISABLED]' ) ;
if ( ! fieldSetElement )
2022-03-28 09:24:58 -07:00
return false ;
2025-03-06 11:46:53 -08:00
const legendElement = fieldSetElement . querySelector ( ':scope > LEGEND' ) ;
return ! legendElement || ! legendElement . contains ( element ) ;
2022-03-28 09:24:58 -07:00
}
2025-04-03 12:53:01 -07:00
function hasExplicitAriaDisabled ( element : Element | undefined , isAncestor = false ) : boolean {
2022-03-28 09:24:58 -07:00
if ( ! element )
return false ;
2025-04-03 12:53:01 -07:00
if ( isAncestor || kAriaDisabledRoles . includes ( getAriaRole ( element ) || '' ) ) {
2022-03-28 09:24:58 -07:00
const attribute = ( element . getAttribute ( 'aria-disabled' ) || '' ) . toLowerCase ( ) ;
if ( attribute === 'true' )
return true ;
if ( attribute === 'false' )
return false ;
2025-04-03 12:53:01 -07:00
// aria-disabled works across shadow boundaries.
return hasExplicitAriaDisabled ( parentElementOrShadowHost ( element ) , true ) ;
2022-03-28 09:24:58 -07:00
}
2025-04-03 12:53:01 -07:00
return false ;
2022-03-28 09:24:58 -07:00
}
2023-06-16 11:39:39 -07:00
2023-10-03 13:01:13 -07:00
function getAccessibleNameFromAssociatedLabels ( labels : Iterable < HTMLLabelElement > , options : AccessibleNameOptions ) {
2024-04-19 12:49:49 -07:00
return [ . . . labels ] . map ( label = > getTextAlternativeInternal ( label , {
2023-10-03 13:01:13 -07:00
. . . options ,
2024-04-17 11:22:09 -07:00
embeddedInLabel : { element : label , hidden : isElementHiddenForAria ( label ) } ,
embeddedInNativeTextAlternative : undefined ,
embeddedInLabelledBy : undefined ,
2024-04-19 12:49:49 -07:00
embeddedInDescribedBy : undefined ,
2024-10-17 17:06:18 -07:00
embeddedInTargetElement : undefined ,
2023-10-03 13:01:13 -07:00
} ) ) . filter ( accessibleName = > ! ! accessibleName ) . join ( ' ' ) ;
}
2025-04-30 17:25:06 -07:00
export function receivesPointerEvents ( element : Element ) : boolean {
const cache = cachePointerEvents ! ;
let e : Element | undefined = element ;
let result : boolean | undefined ;
const parents : Element [ ] = [ ] ;
for ( ; e ; e = parentElementOrShadowHost ( e ! ) ) {
const cached = cache . get ( e ) ;
if ( cached !== undefined ) {
result = cached ;
break ;
}
parents . push ( e ) ;
const style = getElementComputedStyle ( e ) ;
if ( ! style ) {
result = true ;
break ;
}
const value = style . pointerEvents ;
if ( value ) {
result = value !== 'none' ;
break ;
}
}
if ( result === undefined )
result = true ;
for ( const parent of parents )
cache . set ( parent , result ) ;
return result ;
}
2025-04-10 08:30:39 +00:00
let cacheAccessibleName : Map < Element , string > | undefined ;
let cacheAccessibleNameHidden : Map < Element , string > | undefined ;
let cacheAccessibleDescription : Map < Element , string > | undefined ;
let cacheAccessibleDescriptionHidden : Map < Element , string > | undefined ;
let cacheAccessibleErrorMessage : Map < Element , string > | undefined ;
let cacheIsHidden : Map < Element , boolean > | undefined ;
2025-05-08 17:42:13 +00:00
let cachePseudoContent : Map < Element , string | undefined > | undefined ;
let cachePseudoContentBefore : Map < Element , string | undefined > | undefined ;
let cachePseudoContentAfter : Map < Element , string | undefined > | undefined ;
2025-04-30 17:25:06 -07:00
let cachePointerEvents : Map < Element , boolean > | undefined ;
2023-06-16 11:39:39 -07:00
let cachesCounter = 0 ;
2025-04-10 08:30:39 +00:00
export function beginAriaCaches() {
2023-06-16 11:39:39 -07:00
++ cachesCounter ;
2025-04-10 08:30:39 +00:00
cacheAccessibleName ? ? = new Map ( ) ;
cacheAccessibleNameHidden ? ? = new Map ( ) ;
cacheAccessibleDescription ? ? = new Map ( ) ;
cacheAccessibleDescriptionHidden ? ? = new Map ( ) ;
cacheAccessibleErrorMessage ? ? = new Map ( ) ;
cacheIsHidden ? ? = new Map ( ) ;
2025-05-08 17:42:13 +00:00
cachePseudoContent ? ? = new Map ( ) ;
2025-04-10 08:30:39 +00:00
cachePseudoContentBefore ? ? = new Map ( ) ;
cachePseudoContentAfter ? ? = new Map ( ) ;
2025-04-30 17:25:06 -07:00
cachePointerEvents ? ? = new Map ( ) ;
2023-06-16 11:39:39 -07:00
}
export function endAriaCaches() {
if ( ! -- cachesCounter ) {
cacheAccessibleName = undefined ;
cacheAccessibleNameHidden = undefined ;
2024-04-19 12:49:49 -07:00
cacheAccessibleDescription = undefined ;
cacheAccessibleDescriptionHidden = undefined ;
2024-12-27 18:54:16 +09:00
cacheAccessibleErrorMessage = undefined ;
2023-06-16 11:39:39 -07:00
cacheIsHidden = undefined ;
2025-05-08 17:42:13 +00:00
cachePseudoContent = undefined ;
2024-01-23 10:09:23 -08:00
cachePseudoContentBefore = undefined ;
cachePseudoContentAfter = undefined ;
2025-04-30 17:25:06 -07:00
cachePointerEvents = undefined ;
2023-06-16 11:39:39 -07:00
}
}
2024-10-18 20:18:18 -07:00
const inputTypeToRole : Record < string , AriaRole > = {
'button' : 'button' ,
'checkbox' : 'checkbox' ,
'image' : 'button' ,
'number' : 'spinbutton' ,
'radio' : 'radio' ,
'range' : 'slider' ,
'reset' : 'button' ,
'submit' : 'button' ,
} ;