23
23
* SOFTWARE.
24
24
*/
25
25
26
+ import type { ARIARoleDefinitionKey } from 'aria-query' ;
27
+ import { aria , roles } from 'aria-query' ;
28
+ // @ts -expect-error package does not provide types
29
+ import { AXObjectRoles , elementAXObjects } from 'axobject-query' ;
26
30
import type { AuditRuleWithSelector } from './index.js' ;
27
31
28
32
const a11y_required_attributes = {
@@ -125,6 +129,8 @@ const a11y_required_content = [
125
129
126
130
const a11y_distracting_elements = [ 'blink' , 'marquee' ] ;
127
131
132
+ // Unused for now
133
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
128
134
const a11y_nested_implicit_semantics = new Map ( [
129
135
[ 'header' , 'banner' ] ,
130
136
[ 'footer' , 'contentinfo' ] ,
@@ -443,6 +449,61 @@ export const a11y: AuditRuleWithSelector[] = [
443
449
'This will move elements out of the expected tab order, creating a confusing experience for keyboard users.' ,
444
450
selector : '[tabindex]:not([tabindex="-1"]):not([tabindex="0"])' ,
445
451
} ,
452
+ {
453
+ code : 'a11y-role-has-required-aria-props' ,
454
+ title : 'Missing attributes required for ARIA role' ,
455
+ message : ( element ) => {
456
+ const { __astro_role : role , __astro_missing_attributes : required } = element as any ;
457
+ return `${
458
+ element . localName
459
+ } element is missing required attributes for its role (${ role } ): ${ required . join ( ', ' ) } `;
460
+ } ,
461
+ selector : '*' ,
462
+ match ( element ) {
463
+ const role = getRole ( element ) ;
464
+ if ( ! role ) return false ;
465
+ if ( is_semantic_role_element ( role , element . localName , getAttributeObject ( element ) ) ) {
466
+ return ;
467
+ }
468
+ const { requiredProps } = roles . get ( role ) ! ;
469
+ const required_role_props = Object . keys ( requiredProps ) ;
470
+ const missingProps = required_role_props . filter ( ( prop ) => ! element . hasAttribute ( prop ) ) ;
471
+ if ( missingProps . length > 0 ) {
472
+ ( element as any ) . __astro_role = role ;
473
+ ( element as any ) . __astro_missing_attributes = missingProps ;
474
+ return true ;
475
+ }
476
+ } ,
477
+ } ,
478
+
479
+ {
480
+ code : 'a11y-role-supports-aria-props' ,
481
+ title : 'Unsupported ARIA attribute' ,
482
+ message : ( element ) => {
483
+ const { __astro_role : role , __astro_unsupported_attributes : unsupported } = element as any ;
484
+ return `${
485
+ element . localName
486
+ } element has ARIA attributes that are not supported by its role (${ role } ): ${ unsupported . join (
487
+ ', '
488
+ ) } `;
489
+ } ,
490
+ selector : '*' ,
491
+ match ( element ) {
492
+ const role = getRole ( element ) ;
493
+ if ( ! role ) return false ;
494
+ const { props } = roles . get ( role ) ! ;
495
+ const attributes = getAttributeObject ( element ) ;
496
+ const unsupportedAttributes = aria . keys ( ) . filter ( ( attribute ) => ! ( attribute in props ) ) ;
497
+ const invalidAttributes : string [ ] = Object . keys ( attributes ) . filter (
498
+ ( key ) => key . startsWith ( 'aria-' ) && unsupportedAttributes . includes ( key as any )
499
+ ) ;
500
+ if ( invalidAttributes . length > 0 ) {
501
+ ( element as any ) . __astro_role = role ;
502
+ ( element as any ) . __astro_unsupported_attributes = invalidAttributes ;
503
+ return true ;
504
+ }
505
+ } ,
506
+ } ,
446
507
{
447
508
code : 'a11y-structure' ,
448
509
title : 'Invalid DOM structure' ,
@@ -476,6 +537,19 @@ export const a11y: AuditRuleWithSelector[] = [
476
537
} ,
477
538
] ;
478
539
540
+ // Unused for now
541
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
542
+ const a11y_labelable = [
543
+ 'button' ,
544
+ 'input' ,
545
+ 'keygen' ,
546
+ 'meter' ,
547
+ 'output' ,
548
+ 'progress' ,
549
+ 'select' ,
550
+ 'textarea' ,
551
+ ] ;
552
+
479
553
/**
480
554
* Exceptions to the rule which follows common A11y conventions
481
555
* TODO make this configurable by the user
@@ -489,3 +563,81 @@ const a11y_non_interactive_element_to_interactive_role_exceptions = {
489
563
td : [ 'gridcell' ] ,
490
564
fieldset : [ 'radiogroup' , 'presentation' ] ,
491
565
} ;
566
+
567
+ const combobox_if_list = [ 'email' , 'search' , 'tel' , 'text' , 'url' ] ;
568
+ function input_implicit_role ( attributes : Record < string , string > ) {
569
+ if ( ! ( 'type' in attributes ) ) return ;
570
+ const { type, list } = attributes ;
571
+ if ( ! type ) return ;
572
+ if ( list && combobox_if_list . includes ( type ) ) {
573
+ return 'combobox' ;
574
+ }
575
+ return input_type_to_implicit_role . get ( type ) ;
576
+ }
577
+
578
+ /** @param {Map<string, import('#compiler').Attribute> } attribute_map */
579
+ function menuitem_implicit_role ( attributes : Record < string , string > ) {
580
+ if ( ! ( 'type' in attributes ) ) return ;
581
+ const { type } = attributes ;
582
+ if ( ! type ) return ;
583
+ return menuitem_type_to_implicit_role . get ( type ) ;
584
+ }
585
+
586
+ function getRole ( element : Element ) : ARIARoleDefinitionKey | undefined {
587
+ if ( element . hasAttribute ( 'role' ) ) {
588
+ return element . getAttribute ( 'role' ) ! as ARIARoleDefinitionKey ;
589
+ }
590
+ return getImplicitRole ( element ) as ARIARoleDefinitionKey ;
591
+ }
592
+
593
+ function getImplicitRole ( element : Element ) {
594
+ const name = element . localName ;
595
+ const attrs = getAttributeObject ( element ) ;
596
+ if ( name === 'menuitem' ) {
597
+ return menuitem_implicit_role ( attrs ) ;
598
+ } else if ( name === 'input' ) {
599
+ return input_implicit_role ( attrs ) ;
600
+ } else {
601
+ return a11y_implicit_semantics . get ( name ) ;
602
+ }
603
+ }
604
+
605
+ function getAttributeObject ( element : Element ) : Record < string , string > {
606
+ let obj : Record < string , string > = { } ;
607
+ for ( let i = 0 ; i < element . attributes . length ; i ++ ) {
608
+ const attribute = element . attributes . item ( i ) ! ;
609
+ obj [ attribute . name ] = attribute . value ;
610
+ }
611
+ return obj ;
612
+ }
613
+
614
+ /**
615
+ * @param {import('aria-query').ARIARoleDefinitionKey } role
616
+ * @param {string } tag_name
617
+ * @param {Map<string, import('#compiler').Attribute> } attribute_map
618
+ */
619
+ function is_semantic_role_element (
620
+ role : string ,
621
+ tag_name : string ,
622
+ attributes : Record < string , string >
623
+ ) {
624
+ for ( const [ schema , ax_object ] of elementAXObjects . entries ( ) ) {
625
+ if (
626
+ schema . name === tag_name &&
627
+ ( ! schema . attributes ||
628
+ schema . attributes . every ( ( attr : any ) => attributes [ attr . name ] === attr . value ) )
629
+ ) {
630
+ for ( const name of ax_object ) {
631
+ const axRoles = AXObjectRoles . get ( name ) ;
632
+ if ( axRoles ) {
633
+ for ( const { name : _name } of axRoles ) {
634
+ if ( _name === role ) {
635
+ return true ;
636
+ }
637
+ }
638
+ }
639
+ }
640
+ }
641
+ }
642
+ return false ;
643
+ }
0 commit comments