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