1<?php 2 3namespace OOUI; 4 5/** 6 * Element supporting "sequential focus navigation" using the 'tabindex' attribute. 7 * 8 * @abstract 9 */ 10trait TabIndexedElement { 11 /** 12 * Tab index value. 13 * 14 * @var int|null 15 */ 16 protected $tabIndex = null; 17 18 /** 19 * @var Element 20 */ 21 protected $tabIndexed; 22 23 /** 24 * @param array $config Configuration options 25 * - string|int|null $config['tabIndex'] Tab index value. Use 0 to use default ordering, 26 * use -1 to prevent tab focusing, use null to suppress the `tabindex` attribute. 27 * (default: 0) 28 */ 29 public function initializeTabIndexedElement( array $config = [] ) { 30 // Properties 31 $this->tabIndexed = $config['tabIndexed'] ?? $this; 32 33 // Initialization 34 $this->setTabIndex( $config['tabIndex'] ?? 0 ); 35 36 $this->registerConfigCallback( function ( &$config ) { 37 if ( $this->tabIndex !== 0 ) { 38 $config['tabIndex'] = $this->tabIndex; 39 } 40 } ); 41 } 42 43 /** 44 * Set tab index value. 45 * 46 * @param string|int|null $tabIndex Tab index value or null for no tab index 47 * @return $this 48 */ 49 public function setTabIndex( $tabIndex ) { 50 $tabIndex = preg_match( '/^-?\d+$/', $tabIndex ) ? (int)$tabIndex : null; 51 52 if ( $this->tabIndex !== $tabIndex ) { 53 $this->tabIndex = $tabIndex; 54 $this->updateTabIndex(); 55 } 56 57 return $this; 58 } 59 60 /** 61 * Update the tabIndex attribute, in case of changes to tabIndex or disabled 62 * state. 63 * 64 * @return $this 65 */ 66 public function updateTabIndex() { 67 $disabled = $this->isDisabled(); 68 if ( $this->tabIndex !== null ) { 69 $this->tabIndexed->setAttributes( [ 70 // Do not index over disabled elements 71 'tabindex' => $disabled ? -1 : $this->tabIndex, 72 // ChromeVox and NVDA do not seem to inherit this from parent elements 73 'aria-disabled' => ( $disabled ? 'true' : 'false' ) 74 ] ); 75 } else { 76 $this->tabIndexed->removeAttributes( [ 'tabindex', 'aria-disabled' ] ); 77 } 78 return $this; 79 } 80 81 /** 82 * Get tab index value. 83 * 84 * @return int|null Tab index value 85 */ 86 public function getTabIndex() { 87 return $this->tabIndex; 88 } 89 90 /** 91 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value. 92 * 93 * If the element already has an ID then that is returned, otherwise unique ID is 94 * generated, set on the element, and returned. 95 * 96 * @return string|null The ID of the focusable element 97 */ 98 public function getInputId() { 99 $id = $this->tabIndexed->getAttribute( 'id' ); 100 101 if ( !$this->isLabelableNode( $this->tabIndexed ) ) { 102 return null; 103 } 104 105 if ( $id === null ) { 106 $id = Tag::generateElementId(); 107 $this->tabIndexed->setAttributes( [ 'id' => $id ] ); 108 } 109 110 return $id; 111 } 112 113 /** 114 * Whether the node is 'labelable' according to the HTML spec 115 * (i.e., whether it can be interacted with through a `<label for="…">`). 116 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>. 117 * 118 * @param Tag $tag 119 * @return bool 120 */ 121 private function isLabelableNode( Tag $tag ) { 122 $labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ]; 123 $tagName = strtolower( $tag->getTag() ); 124 125 if ( $tagName === 'input' && $tag->getAttribute( 'type' ) !== 'hidden' ) { 126 return true; 127 } 128 if ( in_array( $tagName, $labelableTags, true ) ) { 129 return true; 130 } 131 return false; 132 } 133} 134