blob: 0ef7bef7e898c4f25447852884bbcb3172e706a1 [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001/**
2 * @license
3 * Copyright 2016 Leif Olsen. All Rights Reserved.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 *
17 * This code is built with Google Material Design Lite,
18 * which is Licensed under the Apache License, Version 2.0
19 */
20
21/**
22 * A WAI-ARIA friendly accordion component.
23 * An accordion is a collection of expandable panels associated with a common outer container. Panels consist
24 * of a header and an associated content region or tabpanel. The primary use of an Accordion is to present multiple sections
25 * of content on a single page without scrolling, where all of the sections are peers in the application or object hierarchy.
26 * The general look is similar to a tree where each root tree node is an expandable accordion header. The user navigates
27 * and makes the contents of each panel visible (or not) by interacting with the Accordion Header
28 */
29
30import {
31 VK_ENTER,
32 VK_SPACE,
33 VK_END,
34 VK_HOME,
35 VK_ARROW_LEFT,
36 VK_ARROW_UP,
37 VK_ARROW_RIGHT,
38 VK_ARROW_DOWN,
39 IS_EXPANDED,
40 IS_UPGRADED,
41 ARIA_MULTISELECTABLE,
42 ARIA_EXPANDED,
43 ARIA_HIDDEN,
44 ARIA_SELECTED
45} from '../utils/constants';
46
47
48(function() {
49 'use strict';
50 const ACCORDION = 'mdlext-accordion';
51 const ACCORDION_VERTICAL = 'mdlext-accordion--vertical';
52 const ACCORDION_HORIZONTAL = 'mdlext-accordion--horizontal';
53 const PANEL = 'mdlext-accordion__panel';
54 const PANEL_ROLE = 'presentation';
55 const TAB = 'mdlext-accordion__tab';
56 const TAB_CAPTION = 'mdlext-accordion__tab__caption';
57 const TAB_ROLE = 'tab';
58 const TABPANEL = 'mdlext-accordion__tabpanel';
59 const TABPANEL_ROLE = 'tabpanel';
60 const RIPPLE_EFFECT = 'mdlext-js-ripple-effect';
61 const RIPPLE = 'mdlext-accordion__tab--ripple';
62 const ANIMATION_EFFECT = 'mdlext-js-animation-effect';
63 const ANIMATION = 'mdlext-accordion__tabpanel--animation';
64
65 /**
66 * @constructor
67 * @param {Element} element The element that will be upgraded.
68 */
69 const MaterialExtAccordion = function MaterialExtAccordion( element ) {
70
71 // Stores the Accordion HTML element.
72 this.element_ = element;
73
74 // Initialize instance.
75 this.init();
76 };
77 window['MaterialExtAccordion'] = MaterialExtAccordion;
78
79
80 // Helpers
81 const accordionPanelElements = ( element ) => {
82 if(!element) {
83 return {
84 panel: null,
85 tab: null,
86 tabpanel: null
87 };
88 }
89 else if (element.classList.contains(PANEL)) {
90 return {
91 panel: element,
92 tab: element.querySelector(`.${TAB}`),
93 tabpanel: element.querySelector(`.${TABPANEL}`)
94 };
95 }
96 else {
97 return {
98 panel: element.parentNode,
99 tab: element.parentNode.querySelector(`.${TAB}`),
100 tabpanel: element.parentNode.querySelector(`.${TABPANEL}`)
101 };
102 }
103 };
104
105
106 // Private methods.
107
108 /**
109 * Handles custom command event, 'open', 'close', 'toggle' or upgrade
110 * @param event. A custom event
111 * @private
112 */
113 MaterialExtAccordion.prototype.commandHandler_ = function( event ) {
114 event.preventDefault();
115 event.stopPropagation();
116
117 if(event && event.detail) {
118 this.command(event.detail);
119 }
120 };
121
122 /**
123 * Dispatch toggle event
124 * @param {string} state
125 * @param {Element} tab
126 * @param {Element} tabpanel
127 * @private
128 */
129 MaterialExtAccordion.prototype.dispatchToggleEvent_ = function ( state, tab, tabpanel ) {
130 const ce = new CustomEvent('toggle', {
131 bubbles: true,
132 cancelable: true,
133 detail: { state: state, tab: tab, tabpanel: tabpanel }
134 });
135 this.element_.dispatchEvent(ce);
136 };
137
138 /**
139 * Open tab
140 * @param {Element} panel
141 * @param {Element} tab
142 * @param {Element} tabpanel
143 * @private
144 */
145 MaterialExtAccordion.prototype.openTab_ = function( panel, tab, tabpanel ) {
146 panel.classList.add(IS_EXPANDED);
147 tab.setAttribute(ARIA_EXPANDED, 'true');
148 tabpanel.removeAttribute('hidden');
149 tabpanel.setAttribute(ARIA_HIDDEN, 'false');
150 this.dispatchToggleEvent_('open', tab, tabpanel);
151 };
152
153 /**
154 * Close tab
155 * @param {Element} panel
156 * @param {Element} tab
157 * @param {Element} tabpanel
158 * @private
159 */
160 MaterialExtAccordion.prototype.closeTab_ = function( panel, tab, tabpanel ) {
161 panel.classList.remove(IS_EXPANDED);
162 tab.setAttribute(ARIA_EXPANDED, 'false');
163 tabpanel.setAttribute('hidden', '');
164 tabpanel.setAttribute(ARIA_HIDDEN, 'true');
165 this.dispatchToggleEvent_('close', tab, tabpanel);
166 };
167
168 /**
169 * Toggle tab
170 * @param {Element} panel
171 * @param {Element} tab
172 * @param {Element} tabpanel
173 * @private
174 */
175 MaterialExtAccordion.prototype.toggleTab_ = function( panel, tab, tabpanel ) {
176 if( !(this.element_.hasAttribute('disabled') || tab.hasAttribute('disabled')) ) {
177 if (tab.getAttribute(ARIA_EXPANDED).toLowerCase() === 'true') {
178 this.closeTab_(panel, tab, tabpanel);
179 }
180 else {
181 if (this.element_.getAttribute(ARIA_MULTISELECTABLE).toLowerCase() !== 'true') {
182 this.closeTabs_();
183 }
184 this.openTab_(panel, tab, tabpanel);
185 }
186 }
187 };
188
189 /**
190 * Open tabs
191 * @private
192 */
193 MaterialExtAccordion.prototype.openTabs_ = function() {
194 if (this.element_.getAttribute(ARIA_MULTISELECTABLE).toLowerCase() === 'true') {
195 [...this.element_.querySelectorAll(`.${ACCORDION} > .${PANEL}`)]
196 .filter(panel => !panel.classList.contains(IS_EXPANDED))
197 .forEach(closedItem => {
198 const tab = closedItem.querySelector(`.${TAB}`);
199 if (!tab.hasAttribute('disabled')) {
200 this.openTab_(closedItem, tab, closedItem.querySelector(`.${TABPANEL}`));
201 }
202 });
203 }
204 };
205
206 /**
207 * Close tabs
208 * @private
209 */
210 MaterialExtAccordion.prototype.closeTabs_ = function() {
211 [...this.element_.querySelectorAll(`.${ACCORDION} > .${PANEL}.${IS_EXPANDED}`)]
212 .forEach( panel => {
213 const tab = panel.querySelector(`.${TAB}`);
214 if(!tab.hasAttribute('disabled')) {
215 this.closeTab_(panel, tab, panel.querySelector(`.${TABPANEL}`));
216 }
217 });
218 };
219
220
221 // Public methods.
222
223 /**
224 * Upgrade an individual accordion tab
225 * @public
226 * @param {Element} tabElement The HTML element for the accordion panel.
227 */
228 MaterialExtAccordion.prototype.upgradeTab = function( tabElement ) {
229
230 const { panel, tab, tabpanel } = accordionPanelElements( tabElement );
231
232 const disableTab = () => {
233 panel.classList.remove(IS_EXPANDED);
234 tab.setAttribute('tabindex', '-1');
235 tab.setAttribute(ARIA_EXPANDED, 'false');
236 tabpanel.setAttribute('hidden', '');
237 tabpanel.setAttribute(ARIA_HIDDEN, 'true');
238 };
239
240 const enableTab = () => {
241 if(!tab.hasAttribute(ARIA_EXPANDED)) {
242 tab.setAttribute(ARIA_EXPANDED, 'false');
243 }
244
245 tab.setAttribute('tabindex', '0');
246
247 if(tab.getAttribute(ARIA_EXPANDED).toLowerCase() === 'true') {
248 panel.classList.add(IS_EXPANDED);
249 tabpanel.removeAttribute('hidden');
250 tabpanel.setAttribute(ARIA_HIDDEN, 'false');
251 }
252 else {
253 panel.classList.remove(IS_EXPANDED);
254 tabpanel.setAttribute('hidden', '');
255 tabpanel.setAttribute(ARIA_HIDDEN, 'true');
256 }
257 };
258
259 // In horizontal layout, caption must have a max-width defined to prevent pushing elements to the right of the caption out of view.
260 // In JsDom, offsetWidth and offsetHeight properties do not work, so this function is not testable.
261 /* istanbul ignore next */
262 const calcMaxTabCaptionWidth = () => {
263
264 const tabCaption = tab.querySelector(`.${TAB_CAPTION}`);
265 if(tabCaption !== null) {
266 const w = [...tab.children]
267 .filter( el => el.classList && !el.classList.contains(TAB_CAPTION) )
268 .reduce( (v, el) => v + el.offsetWidth, 0 );
269
270 const maxWidth = tab.clientHeight - w;
271 if(maxWidth > 0) {
272 tabCaption.style['max-width'] = `${maxWidth}px`;
273 }
274 }
275 };
276
277 const selectTab = () => {
278 if( !tab.hasAttribute(ARIA_SELECTED) ) {
279 [...this.element_.querySelectorAll(`.${TAB}[aria-selected="true"]`)].forEach(
280 selectedTab => selectedTab.removeAttribute(ARIA_SELECTED)
281 );
282 tab.setAttribute(ARIA_SELECTED, 'true');
283 }
284 };
285
286 const tabClickHandler = () => {
287 this.toggleTab_(panel, tab, tabpanel);
288 selectTab();
289 };
290
291 const tabFocusHandler = () => {
292 selectTab();
293 };
294
295 const tabpanelClickHandler = () => {
296 selectTab();
297 };
298
299 const tabpanelFocusHandler = () => {
300 selectTab();
301 };
302
303 const tabKeydownHandler = e => {
304
305 if(this.element_.hasAttribute('disabled')) {
306 return;
307 }
308
309 if ( e.keyCode === VK_END || e.keyCode === VK_HOME
310 || e.keyCode === VK_ARROW_UP || e.keyCode === VK_ARROW_LEFT
311 || e.keyCode === VK_ARROW_DOWN || e.keyCode === VK_ARROW_RIGHT ) {
312
313 let nextTab = null;
314 let keyCode = e.keyCode;
315
316 if (keyCode === VK_HOME) {
317 nextTab = this.element_.querySelector(`.${PANEL}:first-child > .${TAB}`);
318 if(nextTab && nextTab.hasAttribute('disabled')) {
319 nextTab = null;
320 keyCode = VK_ARROW_DOWN;
321 }
322 }
323 else if (keyCode === VK_END) {
324 nextTab = this.element_.querySelector(`.${PANEL}:last-child > .${TAB}`);
325 if(nextTab && nextTab.hasAttribute('disabled')) {
326 nextTab = null;
327 keyCode = VK_ARROW_UP;
328 }
329 }
330
331 if(!nextTab) {
332 let nextPanel = panel;
333
334 do {
335 if (keyCode === VK_ARROW_UP || keyCode === VK_ARROW_LEFT) {
336 nextPanel = nextPanel.previousElementSibling;
337 if(!nextPanel) {
338 nextPanel = this.element_.querySelector(`.${PANEL}:last-child`);
339 }
340 if (nextPanel) {
341 nextTab = nextPanel.querySelector(`.${PANEL} > .${TAB}`);
342 }
343 }
344 else if (keyCode === VK_ARROW_DOWN || keyCode === VK_ARROW_RIGHT) {
345 nextPanel = nextPanel.nextElementSibling;
346 if(!nextPanel) {
347 nextPanel = this.element_.querySelector(`.${PANEL}:first-child`);
348 }
349 if (nextPanel) {
350 nextTab = nextPanel.querySelector(`.${PANEL} > .${TAB}`);
351 }
352 }
353
354 if(nextTab && nextTab.hasAttribute('disabled')) {
355 nextTab = null;
356 }
357 else {
358 break;
359 }
360 }
361 while(nextPanel !== panel);
362 }
363
364 if (nextTab) {
365 e.preventDefault();
366 e.stopPropagation();
367 nextTab.focus();
368
369 // Workaround for JSDom testing:
370 // In JsDom 'element.focus()' does not trigger any focus event
371 if(!nextTab.hasAttribute(ARIA_SELECTED)) {
372
373 [...this.element_.querySelectorAll(`.${TAB}[aria-selected="true"]`)]
374 .forEach( selectedTab => selectedTab.removeAttribute(ARIA_SELECTED) );
375
376 nextTab.setAttribute(ARIA_SELECTED, 'true');
377 }
378 }
379 }
380 else if (e.keyCode === VK_ENTER || e.keyCode === VK_SPACE) {
381 e.preventDefault();
382 e.stopPropagation();
383 this.toggleTab_(panel, tab, tabpanel);
384 }
385 };
386
387 if(tab === null) {
388 throw new Error('There must be a tab element for each accordion panel.');
389 }
390
391 if(tabpanel === null) {
392 throw new Error('There must be a tabpanel element for each accordion panel.');
393 }
394
395 panel.setAttribute('role', PANEL_ROLE);
396 tab.setAttribute('role', TAB_ROLE);
397 tabpanel.setAttribute('role', TABPANEL_ROLE);
398
399 if(tab.hasAttribute('disabled')) {
400 disableTab();
401 }
402 else {
403 enableTab();
404 }
405
406 if( this.element_.classList.contains(ACCORDION_HORIZONTAL)) {
407 calcMaxTabCaptionWidth();
408 }
409
410 if (this.element_.classList.contains(RIPPLE_EFFECT)) {
411 tab.classList.add(RIPPLE);
412 }
413
414 if (this.element_.classList.contains(ANIMATION_EFFECT)) {
415 tabpanel.classList.add(ANIMATION);
416 }
417
418 // Remove listeners, just in case ...
419 tab.removeEventListener('click', tabClickHandler);
420 tab.removeEventListener('focus', tabFocusHandler);
421 tab.removeEventListener('keydown', tabKeydownHandler);
422 tabpanel.removeEventListener('click', tabpanelClickHandler);
423 tabpanel.removeEventListener('focus', tabpanelFocusHandler);
424
425 tab.addEventListener('click', tabClickHandler);
426 tab.addEventListener('focus', tabFocusHandler);
427 tab.addEventListener('keydown', tabKeydownHandler);
428 tabpanel.addEventListener('click', tabpanelClickHandler, true);
429 tabpanel.addEventListener('focus', tabpanelFocusHandler, true);
430 };
431 MaterialExtAccordion.prototype['upgradeTab'] = MaterialExtAccordion.prototype.upgradeTab;
432
433
434 /**
435 * Execute command
436 * @param detail
437 */
438 MaterialExtAccordion.prototype.command = function( detail ) {
439
440 const openTab = tabElement => {
441
442 if(tabElement === undefined) {
443 this.openTabs_();
444 }
445 else if(tabElement !== null) {
446 const { panel, tab, tabpanel } = accordionPanelElements( tabElement );
447 if(tab.getAttribute(ARIA_EXPANDED).toLowerCase() !== 'true') {
448 this.toggleTab_(panel, tab, tabpanel);
449 }
450 }
451 };
452
453 const closeTab = tabElement => {
454 if(tabElement === undefined) {
455 this.closeTabs_();
456 }
457 else if(tabElement !== null) {
458 const { panel, tab, tabpanel } = accordionPanelElements( tabElement );
459
460 if(tab.getAttribute(ARIA_EXPANDED).toLowerCase() === 'true') {
461 this.toggleTab_(panel, tab, tabpanel);
462 }
463 }
464 };
465
466 const toggleTab = tabElement => {
467 if(tabElement) {
468 const { panel, tab, tabpanel } = accordionPanelElements( tabElement );
469 this.toggleTab_(panel, tab, tabpanel);
470 }
471 };
472
473
474 if(detail && detail.action) {
475 const { action, target } = detail;
476
477 switch (action.toLowerCase()) {
478 case 'open':
479 openTab(target);
480 break;
481 case 'close':
482 closeTab(target);
483 break;
484 case 'toggle':
485 toggleTab(target);
486 break;
487 case 'upgrade':
488 if(target) {
489 this.upgradeTab(target);
490 }
491 break;
492 default:
493 throw new Error(`Unknown action "${action}". Action must be one of "open", "close", "toggle" or "upgrade"`);
494 }
495 }
496 };
497 MaterialExtAccordion.prototype['command'] = MaterialExtAccordion.prototype.command;
498
499
500 /**
501 * Initialize component
502 */
503 MaterialExtAccordion.prototype.init = function() {
504 if (this.element_) {
505 // Do the init required for this component to work
506 if( !(this.element_.classList.contains(ACCORDION_HORIZONTAL) || this.element_.classList.contains(ACCORDION_VERTICAL))) {
507 throw new Error(`Accordion must have one of the classes "${ACCORDION_HORIZONTAL}" or "${ACCORDION_VERTICAL}"`);
508 }
509
510 this.element_.setAttribute('role', 'tablist');
511
512 if(!this.element_.hasAttribute(ARIA_MULTISELECTABLE)) {
513 this.element_.setAttribute(ARIA_MULTISELECTABLE, 'false');
514 }
515
516 this.element_.removeEventListener('command', this.commandHandler_);
517 this.element_.addEventListener('command', this.commandHandler_.bind(this), false);
518
519 [...this.element_.querySelectorAll(`.${ACCORDION} > .${PANEL}`)].forEach( panel => this.upgradeTab(panel) );
520
521 // Set upgraded flag
522 this.element_.classList.add(IS_UPGRADED);
523 }
524 };
525
526
527 /*
528 * Downgrade component
529 * E.g remove listeners and clean up resources
530 *
531 * Nothing to downgrade
532 *
533 MaterialExtAccordion.prototype.mdlDowngrade_ = function() {
534 'use strict';
535 console.log('***** MaterialExtAccordion.mdlDowngrade');
536 };
537 */
538
539
540 // The component registers itself. It can assume componentHandler is available
541 // in the global scope.
542 /* eslint no-undef: 0 */
543 componentHandler.register({
544 constructor: MaterialExtAccordion,
545 classAsString: 'MaterialExtAccordion',
546 cssClass: 'mdlext-js-accordion',
547 widget: true
548 });
549})();