blob: 862fffb9b864c168cb4b45deb8e9ba7b24a579ba [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001/**
2 * @license
3 * Copyright 2016-2017 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/**
23 * A collapsible is a component to mark expandable and collapsible regions.
24 * The component use the aria-expanded state to indicate whether regions of
25 * the content are collapsible, and to expose whether a region is currently
26 * expanded or collapsed.
27 * @see https://www.w3.org/WAI/GL/wiki/Using_the_WAI-ARIA_aria-expanded_state_to_mark_expandable_and_collapsible_regions
28 */
29
30import {
31 IS_UPGRADED,
32 VK_SPACE,
33 VK_ENTER,
34} from '../utils/constants';
35
36import { randomString } from '../utils/string-utils';
37import { getParentElements, isFocusable } from '../utils/dom-utils';
38
39const JS_COLLAPSIBLE = 'mdlext-js-collapsible';
40const COLLAPSIBLE_CONTROL_CLASS = 'mdlext-collapsible';
41const COLLAPSIBLE_GROUP_CLASS = 'mdlext-collapsible-group';
42const COLLAPSIBLE_REGION_CLASS = 'mdlext-collapsible-region';
43
44/**
45 * The collapsible component
46 */
47
48class Collapsible {
49 element_ = null;
50 controlElement_ = null;
51
52 /**
53 * @constructor
54 * @param {HTMLElement} element The element that this component is connected to.
55 */
56 constructor(element) {
57 this.element_ = element;
58 this.init();
59 }
60
61 keyDownHandler = event => {
62 if (event.keyCode === VK_ENTER || event.keyCode === VK_SPACE) {
63 event.preventDefault();
64
65 // Trigger click
66 (event.target || this.controlElement).dispatchEvent(
67 new MouseEvent('click', {
68 bubbles: true,
69 cancelable: true,
70 view: window
71 })
72 );
73 }
74 };
75
76 clickHandler = event => {
77 if(!this.isDisabled) {
78 if(event.target !== this.controlElement) {
79 // Do not toggle if a focusable element inside the control element triggered the event
80 const p = getParentElements(event.target, this.controlElement);
81 p.push(event.target);
82 if(p.find( el => isFocusable(el))) {
83 return;
84 }
85 }
86 this.toggle();
87 }
88 };
89
90 get element() {
91 return this.element_;
92 }
93
94 get controlElement() {
95 return this.controlElement_;
96 }
97
98 get isDisabled() {
99 return (this.controlElement.hasAttribute('disabled') &&
100 this.controlElement.getAttribute('disabled').toLowerCase() !== 'false') ||
101 (this.controlElement.hasAttribute('aria-disabled') &&
102 this.controlElement.getAttribute('aria-disabled').toLowerCase() !== 'false');
103 }
104
105 get isExpanded() {
106 return this.controlElement.hasAttribute('aria-expanded') &&
107 this.controlElement.getAttribute('aria-expanded').toLowerCase() === 'true';
108 }
109
110 get regionIds() {
111 return this.controlElement.hasAttribute('aria-controls')
112 ? this.controlElement.getAttribute('aria-controls').split(' ')
113 : [];
114 }
115
116 get regionElements() {
117 return this.regionIds
118 .map(id => document.querySelector(`#${id}`))
119 .filter( el => el != null);
120 }
121
122 collapse() {
123 if(!this.isDisabled && this.isExpanded) {
124 if(this.dispatchToggleEvent('collapse')) {
125 this.controlElement.setAttribute('aria-expanded', 'false');
126 const regions = this.regionElements.slice(0);
127 for (let i = regions.length - 1; i >= 0; --i) {
128 regions[i].setAttribute('hidden', '');
129 }
130 }
131 }
132 }
133
134 expand() {
135 if(!this.isDisabled && !this.isExpanded) {
136 if(this.dispatchToggleEvent('expand')) {
137 this.controlElement.setAttribute('aria-expanded', 'true');
138 this.regionElements.forEach(region => region.removeAttribute('hidden'));
139 }
140 }
141 }
142
143 toggle() {
144 if (this.isExpanded) {
145 this.collapse();
146 }
147 else {
148 this.expand();
149 }
150 }
151
152 dispatchToggleEvent(action) {
153 return this.element.dispatchEvent(
154 new CustomEvent('toggle', {
155 bubbles: true,
156 cancelable: true,
157 detail: {
158 action: action
159 }
160 })
161 );
162 }
163
164 disableToggle() {
165 this.controlElement.setAttribute('aria-disabled', true);
166 }
167
168 enableToggle() {
169 this.controlElement.removeAttribute('aria-disabled');
170 }
171
172 addRegionId(regionId) {
173 const ids = this.regionIds;
174 if(!ids.find(id => regionId === id)) {
175 ids.push(regionId);
176 this.controlElement.setAttribute('aria-controls', ids.join(' '));
177 }
178 }
179
180 addRegionElement(region) {
181 if(!(region.classList.contains(COLLAPSIBLE_GROUP_CLASS) ||
182 region.classList.contains(COLLAPSIBLE_REGION_CLASS))) {
183 region.classList.add(COLLAPSIBLE_GROUP_CLASS);
184 }
185
186 if(!region.hasAttribute('role')) {
187 const role = region.classList.contains(COLLAPSIBLE_GROUP_CLASS) ? 'group' : 'region';
188 region.setAttribute('role', role);
189 }
190
191 if(!region.hasAttribute('id')) {
192 region.id = `${region.getAttribute('role')}-${randomString()}`;
193 }
194
195 if(this.isExpanded) {
196 region.removeAttribute('hidden');
197 }
198 else {
199 region.setAttribute('hidden', '');
200 }
201 this.addRegionId(region.id);
202 }
203
204 removeRegionElement(region) {
205 if(region && region.id) {
206 const ids = this.regionIds.filter(id => id === region.id);
207 this.controlElement.setAttribute('aria-controls', ids.join(' '));
208 }
209 }
210
211 removeListeners() {
212 this.controlElement.removeEventListener('keydown', this.keyDownHandler);
213 this.controlElement.removeEventListener('click', this.clickHandler);
214 }
215
216 init() {
217 const initControl = () => {
218 // Find the button element
219 this.controlElement_ = this.element.querySelector(`.${COLLAPSIBLE_CONTROL_CLASS}`) || this.element;
220
221 // Add "aria-expanded" attribute if not present
222 if(!this.controlElement.hasAttribute('aria-expanded')) {
223 this.controlElement.setAttribute('aria-expanded', 'false');
224 }
225
226 // Add role=button if control != <button>
227 if(this.controlElement.nodeName.toLowerCase() !== 'button') {
228 this.controlElement.setAttribute('role', 'button');
229 }
230
231 // Add tabindex
232 if(!isFocusable(this.controlElement) && !this.controlElement.hasAttribute('tabindex')) {
233 this.controlElement.setAttribute('tabindex', '0');
234 }
235 };
236
237 const initRegions = () => {
238 let regions = [];
239 if(!this.controlElement.hasAttribute('aria-controls')) {
240 // Add siblings as collapsible region(s)
241 let r = this.element.nextElementSibling;
242 while(r) {
243 if(r.classList.contains(COLLAPSIBLE_GROUP_CLASS) ||
244 r.classList.contains(COLLAPSIBLE_REGION_CLASS)) {
245 regions.push(r);
246 }
247 else if(r.classList.contains(JS_COLLAPSIBLE)) {
248 // A new collapsible component
249 break;
250 }
251 r = r.nextElementSibling;
252 }
253 }
254 else {
255 regions = this.regionElements;
256 }
257 regions.forEach(region => this.addRegionElement(region));
258 };
259
260 const addListeners = () => {
261 this.controlElement.addEventListener('keydown', this.keyDownHandler);
262 this.controlElement.addEventListener('click', this.clickHandler);
263 };
264
265 initControl();
266 initRegions();
267 this.removeListeners();
268 addListeners();
269 }
270
271 downgrade() {
272 this.removeListeners();
273 }
274
275}
276
277(function() {
278 'use strict';
279
280 /**
281 * @constructor
282 * @param {HTMLElement} element The element that will be upgraded.
283 */
284 const MaterialExtCollapsible = function MaterialExtCollapsible(element) {
285 this.element_ = element;
286 this.collapsible = null;
287
288 // Initialize instance.
289 this.init();
290 };
291 window['MaterialExtCollapsible'] = MaterialExtCollapsible;
292
293 /**
294 * Initialize component
295 */
296 MaterialExtCollapsible.prototype.init = function() {
297 if (this.element_) {
298 this.collapsible = new Collapsible(this.element_);
299 this.element_.classList.add(IS_UPGRADED);
300
301 // Listen to 'mdl-componentdowngraded' event
302 this.element_.addEventListener('mdl-componentdowngraded', this.mdlDowngrade_.bind(this));
303 }
304 };
305
306 /*
307 * Downgrade component
308 * E.g remove listeners and clean up resources
309 */
310 MaterialExtCollapsible.prototype.mdlDowngrade_ = function() {
311 this.collapsible.downgrade();
312 };
313
314
315 // Public methods.
316
317 /**
318 * Get control element.
319 * @return {HTMLElement} element The element that controls the collapsible region.
320 * @public
321 */
322 MaterialExtCollapsible.prototype.getControlElement = function() {
323 return this.collapsible.controlElement;
324 };
325 MaterialExtCollapsible.prototype['getControlElement'] = MaterialExtCollapsible.prototype.getControlElement;
326
327 /**
328 * Get region elements controlled by this collapsible
329 * @returns {Array<HTMLElement>} the collapsible region elements
330 * @public
331 */
332 MaterialExtCollapsible.prototype.getRegionElements = function() {
333 return this.collapsible.regionElements;
334 };
335 MaterialExtCollapsible.prototype['getRegionElements'] = MaterialExtCollapsible.prototype.getRegionElements;
336
337 /**
338 * Add region elements.
339 * @param {Array<HTMLElement>} elements The element that will be upgraded.
340 * @return {void}
341 * @public
342 */
343 MaterialExtCollapsible.prototype.addRegionElements = function(...elements) {
344 elements.forEach(element => this.collapsible.addRegionElement(element));
345 };
346 MaterialExtCollapsible.prototype['addRegionElements'] = MaterialExtCollapsible.prototype.addRegionElements;
347
348 /**
349 * Remove collapsible region(s) from component.
350 * Note: This operation does not delete the element from the DOM tree.
351 * @param {Array<HTMLElement>} elements The element that will be upgraded.
352 * @public
353 */
354 MaterialExtCollapsible.prototype.removeRegionElements = function(...elements) {
355 elements.forEach(element => this.collapsible.removeRegionElement(element));
356 };
357 MaterialExtCollapsible.prototype['removeRegionElements'] = MaterialExtCollapsible.prototype.removeRegionElements;
358
359 /**
360 * Expand collapsible region(s)
361 * @return {void}
362 * @public
363 */
364 MaterialExtCollapsible.prototype.expand = function() {
365 this.collapsible.expand();
366 };
367 MaterialExtCollapsible.prototype['expand'] = MaterialExtCollapsible.prototype.expand;
368
369 /**
370 * Collapse collapsible region(s)
371 * @return {void}
372 * @public
373 */
374 MaterialExtCollapsible.prototype.collapse = function() {
375 this.collapsible.collapse();
376 };
377 MaterialExtCollapsible.prototype['collapse'] = MaterialExtCollapsible.prototype.collapse;
378
379 /**
380 * Toggle collapsible region(s)
381 * @return {void}
382 * @public
383 */
384 MaterialExtCollapsible.prototype.toggle = function() {
385 this.collapsible.toggle();
386 };
387 MaterialExtCollapsible.prototype['toggle'] = MaterialExtCollapsible.prototype.toggle;
388
389 /**
390 * Check whether component has aria-expanded state true
391 * @return {Boolean} true if aria-expanded="true", otherwise false
392 */
393 MaterialExtCollapsible.prototype.isExpanded = function() {
394 return this.collapsible.isExpanded;
395 };
396 MaterialExtCollapsible.prototype['isExpanded'] = MaterialExtCollapsible.prototype.isExpanded;
397
398 /**
399 * Check whether component has aria-disabled state set to true
400 * @return {Boolean} true if aria-disabled="true", otherwise false
401 */
402 MaterialExtCollapsible.prototype.isDisabled = function() {
403 return this.collapsible.isDisabled;
404 };
405 MaterialExtCollapsible.prototype['isDisabled'] = MaterialExtCollapsible.prototype.isDisabled;
406
407 /**
408 * Disables toggling of collapsible region(s)
409 * @return {void}
410 * @public
411 */
412 MaterialExtCollapsible.prototype.disableToggle = function() {
413 this.collapsible.disableToggle();
414 };
415 MaterialExtCollapsible.prototype['disableToggle'] = MaterialExtCollapsible.prototype.disableToggle;
416
417 /**
418 * Enables toggling of collapsible region(s)
419 * @return {void}
420 * @public
421 */
422 MaterialExtCollapsible.prototype.enableToggle = function() {
423 this.collapsible.enableToggle();
424 };
425 MaterialExtCollapsible.prototype['enableToggle'] = MaterialExtCollapsible.prototype.enableToggle;
426
427 // The component registers itself. It can assume componentHandler is available
428 // in the global scope.
429 /* eslint no-undef: 0 */
430 componentHandler.register({
431 constructor: MaterialExtCollapsible,
432 classAsString: 'MaterialExtCollapsible',
433 cssClass: JS_COLLAPSIBLE,
434 widget: true
435 });
436
437})();