-
Notifications
You must be signed in to change notification settings - Fork 4.3k
/
tabbable.js
188 lines (163 loc) · 5.53 KB
/
tabbable.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
/**
* External dependencies
*/
import { without, first, last } from 'lodash';
/**
* Internal dependencies
*/
import { find as findFocusable } from './focusable';
/**
* Returns the tab index of the given element. In contrast with the tabIndex
* property, this normalizes the default (0) to avoid browser inconsistencies,
* operating under the assumption that this function is only ever called with a
* focusable node.
*
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=1190261
*
* @param {Element} element Element from which to retrieve.
*
* @return {number} Tab index of element (default 0).
*/
function getTabIndex( element ) {
const tabIndex = element.getAttribute( 'tabindex' );
return tabIndex === null ? 0 : parseInt( tabIndex, 10 );
}
/**
* Returns true if the specified element is tabbable, or false otherwise.
*
* @param {Element} element Element to test.
*
* @return {boolean} Whether element is tabbable.
*/
export function isTabbableIndex( element ) {
return getTabIndex( element ) !== -1;
}
/** @typedef {Element & { type?: string, checked?: boolean, name?: string }} MaybeHTMLInputElement */
/**
* Returns a stateful reducer function which constructs a filtered array of
* tabbable elements, where at most one radio input is selected for a given
* name, giving priority to checked input, falling back to the first
* encountered.
*
* @return {(acc: MaybeHTMLInputElement[], el: MaybeHTMLInputElement) => MaybeHTMLInputElement[]} Radio group collapse reducer.
*/
function createStatefulCollapseRadioGroup() {
/** @type {Record<string, MaybeHTMLInputElement>} */
const CHOSEN_RADIO_BY_NAME = {};
return function collapseRadioGroup(
/** @type {MaybeHTMLInputElement[]} */ result,
/** @type {MaybeHTMLInputElement} */ element
) {
const { nodeName, type, checked, name } = element;
// For all non-radio tabbables, construct to array by concatenating.
if ( nodeName !== 'INPUT' || type !== 'radio' || ! name ) {
return result.concat( element );
}
const hasChosen = CHOSEN_RADIO_BY_NAME.hasOwnProperty( name );
// Omit by skipping concatenation if the radio element is not chosen.
const isChosen = checked || ! hasChosen;
if ( ! isChosen ) {
return result;
}
// At this point, if there had been a chosen element, the current
// element is checked and should take priority. Retroactively remove
// the element which had previously been considered the chosen one.
if ( hasChosen ) {
const hadChosenElement = CHOSEN_RADIO_BY_NAME[ name ];
result = without( result, hadChosenElement );
}
CHOSEN_RADIO_BY_NAME[ name ] = element;
return result.concat( element );
};
}
/**
* An array map callback, returning an object with the element value and its
* array index location as properties. This is used to emulate a proper stable
* sort where equal tabIndex should be left in order of their occurrence in the
* document.
*
* @param {Element} element Element.
* @param {number} index Array index of element.
*
* @return {{ element: Element, index: number }} Mapped object with element, index.
*/
function mapElementToObjectTabbable( element, index ) {
return { element, index };
}
/**
* An array map callback, returning an element of the given mapped object's
* element value.
*
* @param {{ element: Element }} object Mapped object with element.
*
* @return {Element} Mapped object element.
*/
function mapObjectTabbableToElement( object ) {
return object.element;
}
/**
* A sort comparator function used in comparing two objects of mapped elements.
*
* @see mapElementToObjectTabbable
*
* @param {{ element: Element, index: number }} a First object to compare.
* @param {{ element: Element, index: number }} b Second object to compare.
*
* @return {number} Comparator result.
*/
function compareObjectTabbables( a, b ) {
const aTabIndex = getTabIndex( a.element );
const bTabIndex = getTabIndex( b.element );
if ( aTabIndex === bTabIndex ) {
return a.index - b.index;
}
return aTabIndex - bTabIndex;
}
/**
* Givin focusable elements, filters out tabbable element.
*
* @param {Element[]} focusables Focusable elements to filter.
*
* @return {Element[]} Tabbable elements.
*/
function filterTabbable( focusables ) {
return focusables
.filter( isTabbableIndex )
.map( mapElementToObjectTabbable )
.sort( compareObjectTabbables )
.map( mapObjectTabbableToElement )
.reduce( createStatefulCollapseRadioGroup(), [] );
}
/**
* @param {Element} context
* @return {Element[]} Tabbable elements within the context.
*/
export function find( context ) {
return filterTabbable( findFocusable( context ) );
}
/**
* Given a focusable element, find the preceding tabbable element.
*
* @param {Element} element The focusable element before which to look. Defaults
* to the active element.
*/
export function findPrevious( element ) {
const focusables = findFocusable( element.ownerDocument.body );
const index = focusables.indexOf( element );
// Remove all focusables after and including `element`.
focusables.length = index;
return last( filterTabbable( focusables ) );
}
/**
* Given a focusable element, find the next tabbable element.
*
* @param {Element} element The focusable element after which to look. Defaults
* to the active element.
*/
export function findNext( element ) {
const focusables = findFocusable( element.ownerDocument.body );
const index = focusables.indexOf( element );
// Remove all focusables before and including `element`.
const remaining = focusables.slice( index + 1 );
return first( filterTabbable( remaining ) );
}