14
14
* limitations under the License.
15
15
*/
16
16
17
+ import * as css from '@isomorphic/cssTokenizer' ;
18
+
17
19
import { getGlobalOptions , closestCrossShadow , elementSafeTagName , enclosingShadowRootOrDocument , getElementComputedStyle , isElementStyleVisibilityVisible , isVisibleTextNode , parentElementOrShadowHost } from './domUtils' ;
18
20
19
21
import type { AriaRole } from '@isomorphic/ariaSnapshot' ;
@@ -355,41 +357,80 @@ function queryInAriaOwned(element: Element, selector: string): Element[] {
355
357
return result ;
356
358
}
357
359
358
- export function getPseudoContent ( element : Element , pseudo : '::before' | '::after' ) {
359
- const cache = pseudo === '::before' ? cachePseudoContentBefore : cachePseudoContentAfter ;
360
+ export function getCSSContent ( element : Element , pseudo ?: '::before' | '::after' ) {
361
+ // Relevant spec: 2.6.2 from https://w3c.github.io/accname/#computation-steps.
362
+ // Additional considerations: https://github.com/w3c/accname/issues/204.
363
+ const cache = pseudo === '::before' ? cachePseudoContentBefore : ( pseudo === '::after' ? cachePseudoContentAfter : cachePseudoContent ) ;
360
364
if ( cache ?. has ( element ) )
361
- return cache ?. get ( element ) || '' ;
362
- const pseudoStyle = getElementComputedStyle ( element , pseudo ) ;
363
- const content = getPseudoContentImpl ( element , pseudoStyle ) ;
364
- if ( cache )
365
- cache . set ( element , content ) ;
366
- return content ;
367
- }
365
+ return cache ?. get ( element ) ;
368
366
369
- function getPseudoContentImpl ( element : Element , pseudoStyle : CSSStyleDeclaration | undefined ) {
370
- // Note: all browsers ignore display:none and visibility:hidden pseudos.
371
- if ( ! pseudoStyle || pseudoStyle . display === 'none' || pseudoStyle . visibility === 'hidden' )
372
- return '' ;
373
- const content = pseudoStyle . content ;
374
- let resolvedContent : string | undefined ;
375
- if ( ( content [ 0 ] === '\'' && content [ content . length - 1 ] === '\'' ) ||
376
- ( content [ 0 ] === '"' && content [ content . length - 1 ] === '"' ) ) {
377
- resolvedContent = content . substring ( 1 , content . length - 1 ) ;
378
- } else if ( content . startsWith ( 'attr(' ) && content . endsWith ( ')' ) ) {
379
- // Firefox does not resolve attribute accessors in content.
380
- const attrName = content . substring ( 'attr(' . length , content . length - 1 ) . trim ( ) ;
381
- resolvedContent = element . getAttribute ( attrName ) || '' ;
367
+ const style = getElementComputedStyle ( element , pseudo ) ;
368
+ let content : string | undefined ;
369
+ if ( style && style . display !== 'none' && style . visibility !== 'hidden' ) {
370
+ // Note: all browsers ignore display:none and visibility:hidden pseudos.
371
+ content = parseCSSContentPropertyAsString ( element , style . content , ! ! pseudo ) ;
382
372
}
383
- if ( resolvedContent !== undefined ) {
373
+
374
+ if ( pseudo && content !== undefined ) {
384
375
// SPEC DIFFERENCE.
385
376
// Spec says "CSS textual content, without a space", but we account for display
386
377
// to pass "name_file-label-inline-block-styles-manual.html"
387
- const display = pseudoStyle . display || 'inline' ;
378
+ const display = style ? .display || 'inline' ;
388
379
if ( display !== 'inline' )
389
- return ' ' + resolvedContent + ' ' ;
390
- return resolvedContent ;
380
+ content = ' ' + content + ' ' ;
381
+ }
382
+
383
+ if ( cache )
384
+ cache . set ( element , content ) ;
385
+ return content ;
386
+ }
387
+
388
+ function parseCSSContentPropertyAsString ( element : Element , content : string , isPseudo : boolean ) : string | undefined {
389
+ // Welcome to the mini CSS parser!
390
+ // It aims to support the following syntax and any subset of it:
391
+ // content: "one" attr(...) "two" "three" / "alt" attr(...) "more alt"
392
+ // See https://developer.mozilla.org/en-US/docs/Web/CSS/content for more details.
393
+
394
+ if ( ! content || content === 'none' || content === 'normal' ) {
395
+ // Common fast path.
396
+ return ;
397
+ }
398
+
399
+ try {
400
+ let tokens = css . tokenize ( content ) . filter ( token => ! ( token instanceof css . WhitespaceToken ) ) ;
401
+ const delimIndex = tokens . findIndex ( token => token instanceof css . DelimToken && token . value === '/' ) ;
402
+ if ( delimIndex !== - 1 ) {
403
+ // Use the alternative text part when exists.
404
+ // content: ... / "alternative text"
405
+ tokens = tokens . slice ( delimIndex + 1 ) ;
406
+ } else if ( ! isPseudo ) {
407
+ // For non-pseudo elements, the only valid content is a url() or various gradients.
408
+ // Therefore, we follow Chrome and only consider the alternative text.
409
+ // Firefox, on the other hand, calculates accessible name to be empty.
410
+ return ;
411
+ }
412
+
413
+ const accumulated : string [ ] = [ ] ;
414
+ let index = 0 ;
415
+ while ( index < tokens . length ) {
416
+ if ( tokens [ index ] instanceof css . StringToken ) {
417
+ // content: "some text"
418
+ accumulated . push ( tokens [ index ] . value as string ) ;
419
+ index ++ ;
420
+ } 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 ) {
421
+ // content: attr(...)
422
+ // Firefox does not resolve attribute accessors in content, so we do it manually.
423
+ const attrName = tokens [ index + 1 ] . value as string ;
424
+ accumulated . push ( element . getAttribute ( attrName ) || '' ) ;
425
+ index += 3 ;
426
+ } else {
427
+ // Failed to parse the content, so ignore it.
428
+ return ;
429
+ }
430
+ }
431
+ return accumulated . join ( '' ) ;
432
+ } catch {
391
433
}
392
- return '' ;
393
434
}
394
435
395
436
export function getAriaLabelledByElements ( element : Element ) : Element [ ] | null {
@@ -879,22 +920,31 @@ function innerAccumulatedElementText(element: Element, options: AccessibleNameOp
879
920
tokens . push ( node . textContent || '' ) ;
880
921
}
881
922
} ;
882
- tokens . push ( getPseudoContent ( element , '::before' ) ) ;
883
- const assignedNodes = element . nodeName === 'SLOT' ? ( element as HTMLSlotElement ) . assignedNodes ( ) : [ ] ;
884
- if ( assignedNodes . length ) {
885
- for ( const child of assignedNodes )
886
- visit ( child , false ) ;
923
+ tokens . push ( getCSSContent ( element , '::before' ) || '' ) ;
924
+ const content = getCSSContent ( element ) ;
925
+ if ( content !== undefined ) {
926
+ // `content` CSS property replaces everything inside the element.
927
+ // I was not able to find any spec or description on how this interacts with accname,
928
+ // so this is a guess based on what browsers do.
929
+ tokens . push ( content ) ;
887
930
} else {
888
- for ( let child = element . firstChild ; child ; child = child . nextSibling )
889
- visit ( child , true ) ;
890
- if ( element . shadowRoot ) {
891
- for ( let child = element . shadowRoot . firstChild ; child ; child = child . nextSibling )
931
+ // step 2h.
932
+ const assignedNodes = element . nodeName === 'SLOT' ? ( element as HTMLSlotElement ) . assignedNodes ( ) : [ ] ;
933
+ if ( assignedNodes . length ) {
934
+ for ( const child of assignedNodes )
935
+ visit ( child , false ) ;
936
+ } else {
937
+ for ( let child = element . firstChild ; child ; child = child . nextSibling )
892
938
visit ( child , true ) ;
939
+ if ( element . shadowRoot ) {
940
+ for ( let child = element . shadowRoot . firstChild ; child ; child = child . nextSibling )
941
+ visit ( child , true ) ;
942
+ }
943
+ for ( const owned of getIdRefs ( element , element . getAttribute ( 'aria-owns' ) ) )
944
+ visit ( owned , true ) ;
893
945
}
894
- for ( const owned of getIdRefs ( element , element . getAttribute ( 'aria-owns' ) ) )
895
- visit ( owned , true ) ;
896
946
}
897
- tokens . push ( getPseudoContent ( element , '::after' ) ) ;
947
+ tokens . push ( getCSSContent ( element , '::after' ) || '' ) ;
898
948
return tokens . join ( '' ) ;
899
949
}
900
950
@@ -1091,8 +1141,9 @@ let cacheAccessibleDescription: Map<Element, string> | undefined;
1091
1141
let cacheAccessibleDescriptionHidden : Map < Element , string > | undefined ;
1092
1142
let cacheAccessibleErrorMessage : Map < Element , string > | undefined ;
1093
1143
let cacheIsHidden : Map < Element , boolean > | undefined ;
1094
- let cachePseudoContentBefore : Map < Element , string > | undefined ;
1095
- let cachePseudoContentAfter : Map < Element , string > | undefined ;
1144
+ let cachePseudoContent : Map < Element , string | undefined > | undefined ;
1145
+ let cachePseudoContentBefore : Map < Element , string | undefined > | undefined ;
1146
+ let cachePseudoContentAfter : Map < Element , string | undefined > | undefined ;
1096
1147
let cachePointerEvents : Map < Element , boolean > | undefined ;
1097
1148
let cachesCounter = 0 ;
1098
1149
@@ -1104,6 +1155,7 @@ export function beginAriaCaches() {
1104
1155
cacheAccessibleDescriptionHidden ??= new Map ( ) ;
1105
1156
cacheAccessibleErrorMessage ??= new Map ( ) ;
1106
1157
cacheIsHidden ??= new Map ( ) ;
1158
+ cachePseudoContent ??= new Map ( ) ;
1107
1159
cachePseudoContentBefore ??= new Map ( ) ;
1108
1160
cachePseudoContentAfter ??= new Map ( ) ;
1109
1161
cachePointerEvents ??= new Map ( ) ;
@@ -1117,6 +1169,7 @@ export function endAriaCaches() {
1117
1169
cacheAccessibleDescriptionHidden = undefined ;
1118
1170
cacheAccessibleErrorMessage = undefined ;
1119
1171
cacheIsHidden = undefined ;
1172
+ cachePseudoContent = undefined ;
1120
1173
cachePseudoContentBefore = undefined ;
1121
1174
cachePseudoContentAfter = undefined ;
1122
1175
cachePointerEvents = undefined ;
0 commit comments