-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy patha11y-tabs.js
198 lines (186 loc) · 6.57 KB
/
a11y-tabs.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
189
190
191
192
193
194
195
196
197
198
/**
* Tabs — a lightweight JavaScript package to facilitate a11y-compliant tabbed interfaces
*/
export default class Tabs {
tabs = [];
tabItems = null;
panels = null;
currentIndex = -1;
isVerticalOrientation = false;
/**
*
* @param {string} tabListSelector A CSS selector to pointing to your tablist — the parent element to your tab buttons
* which must have attribute `[role="tablist"]` as we will combine this selector to find all its child tab buttons with
* `role="tab"`. So your tab buttons must all have the `[role="tab"]` attributes. These should also contain `aria-controls`
* attribute pointing to the ID of corresponding tab-panel. For example:
* `<div role="tablist"><button class="tab-item tab-button" role="tab" aria-controls="panel-1">Tab 1</button>...and so on`
* @param {string} panelsSelector A CSS selector to find by `querySelectorAll`, your panel items. This selector
* must be able to find all panels with role="tabpanel" items that correspond to your tab button's `aria-controls` IDs:
* ```js
* <div id="panel-1" role="tabpanel" tab-title="Tab 1">
* <div>Tab 1 content.</div>
* </div>
* ```
* @param {number} activeIndex The index of the initial tab you'd like opened. Defaults to 0.
*/
constructor(tabListSelector, panelsSelector, activeIndex) {
const initialActiveIndex = activeIndex || 0;
this.tabItems = document.querySelectorAll(`${tabListSelector} [role="tab"]`);
this.panels = document.querySelectorAll(panelsSelector);
const tablist = document.querySelector(tabListSelector);
const orientation = tablist.getAttribute('aria-orientation');
this.isVerticalOrientation = orientation && orientation === 'vertical' ? true : false;
this.currentIndex = initialActiveIndex;
this.selectTab = this.selectTab.bind(this);
this.deselectTabs = this.deselectTabs.bind(this);
this.resetPanels = this.resetPanels.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
this.initTabs();
}
/**
* Only ran once from our contructor this sets up the tabs, panels, and adds click and keydown listeners
*/
initTabs() {
this.resetPanels();
this.deselectTabs();
for (let i = 0; i < this.tabItems.length; i++) {
const tab = this.tabItems[i];
this.tabs[i] = tab;
this.tabs[i].index = i;
tab.addEventListener('click', this.handleClick);
tab.addEventListener('keydown', this.handleKeydown);
if (i === this.currentIndex) {
this._selectTab(tab);
}
}
}
/**
* Activates a panel by removing it's hidden attribute and setting aria-expanded to true
* @param {string} panelId a tab panel's ID obtained from the `aria-controls` attribute
*/
activatePanel(panelId) {
document.querySelector(`#${panelId}`).removeAttribute('hidden');
document.querySelector(`#${panelId}`).setAttribute('aria-expanded', true);
}
/**
* Used to remove .active class from all tabs, set to tabindex -1, and aria-selected false.
*/
deselectTabs() {
for (let i = 0; i < this.tabItems.length; i++) {
const tab = this.tabItems[i];
tab.classList.remove('active');
tab.setAttribute('tabindex', '-1');
tab.setAttribute('aria-selected', false);
}
}
/**
* Essentially, will set all tab panels to hidden, aria-expanded false, and tabindex 0 (so they can be tabbed into)
*/
resetPanels() {
for (let j = 0; j < this.panels.length; j++) {
const panel = this.panels[j];
panel.setAttribute('hidden', true);
panel.setAttribute('aria-expanded', false);
panel.setAttribute('tabindex', '0');
}
}
/**
* Sets the selected tab and delegates to `activatePanel` to, ultimately, select a new
* tab / tab panel pair that is selected aka expanded.
*
* @param {HTMLElement} tabElement the tab button element to select
*/
selectTab(tabElement) {
this.deselectTabs();
this.resetPanels();
this._selectTab(tabElement);
}
_selectTab(tabElement) {
tabElement.classList.add('active');
tabElement.setAttribute('aria-selected', 'true');
tabElement.removeAttribute('tabindex');
this.activatePanel(tabElement.getAttribute('aria-controls'));
}
/**
*
* @param {Event} ev fired when user clicks or presses <space | enter> on a tab button
*/
handleClick(ev) {
this.selectTab(ev.target);
}
decrementIndex() {
this.currentIndex--;
if (this.currentIndex < 0) {
this.currentIndex = this.tabItems.length - 1;
}
}
incrementIndex() {
this.currentIndex++;
if (this.currentIndex >= this.tabItems.length) {
this.currentIndex = 0;
}
}
/**
* This is used to implement our a11y keyboard navigation.
*
* @param {Event} ev fires when the user presses a key
* @returns
*/
handleKeydown(ev) {
switch (ev.key) {
case 'Up': // IE Edge
case 'ArrowUp':
// If orientation vertical update current index else "fall-through" so default return
if (this.isVerticalOrientation) {
this.decrementIndex();
break;
}
case 'Down': // IE Edge
case 'ArrowDown':
// If orientation vertical update current index else "fall-through" so default return
if (this.isVerticalOrientation) {
this.incrementIndex();
break;
}
case 'Left': // IE Edge
case 'ArrowLeft':
// If orientation horizontal update current index else "fall-through" so default return
if (!this.isVerticalOrientation) {
this.decrementIndex();
break;
}
case 'Right': // IE Edge
case 'ArrowRight':
// If orientation horizontal update current index else "fall-through" so default return
if (!this.isVerticalOrientation) {
this.incrementIndex();
break;
}
case 'Home':
case 'ArrowHome':
this.currentIndex = 0;
break;
case 'End':
case 'ArrowEnd':
this.currentIndex = this.tabItems.length - 1;
break;
case 'Enter':
case 'Space':
this.selectTab(ev.target);
ev.target.focus();
break;
default:
// If an unaccounted for key is pressed returning here prevents the
// `preventDefault` below (thus preserving normal behavior)
return;
}
ev.preventDefault();
/**
* Ensure we have proper focus on currently selected tab. We do
* NOT want to set its tabindex yet though; that only happens when
* we click, enter, or space on a tab button.
*/
this.tabs[this.currentIndex].focus();
}
}