1/** 2 * @file AccordionCheckbox.js 3 * @description A custom web component that displays an accordion with grouped checkboxes. 4 * 5 * @class AccordionCheckboxElement 6 * @extends HTMLElement 7 * 8 * @example 9 * <accordion-checkbox-element 10 * name="export_columns" 11 * show-select-all="true"> 12 * </accordion-checkbox-element> 13 * 14 * // JavaScript to set data 15 * const accordion = document.querySelector('accordion-checkbox-element'); 16 * accordion.setData({ 17 * 'Attendee information': [ 18 * { value: 'first_name', label: 'First Name' }, 19 * { value: 'last_name', label: 'Last Name' } 20 * ], 21 * 'Ticket details': [ 22 * { value: 'ticket_name', label: 'Ticket Name' }, 23 * { value: 'seat', label: 'Seat #' } 24 * ] 25 * }); 26 * 27 * @attribute {string} name - The name attribute for checkboxes (e.g., "export_columns[]") 28 * @attribute {boolean} show-select-all - Whether to show the global "Select All" option 29 * @attribute {string} columns - Number of columns in the grid (default: 3) 30 * 31 * @method setData - Sets the accordion data structure 32 * @method getData - Returns an array of selected checkbox values 33 * @method reset - Unchecks all checkboxes 34 */ 35class AccordionCheckboxElement extends HTMLElement { 36 constructor() { 37 super(); 38 this.attachShadow({ mode: 'open' }); 39 40 // Data structure 41 this.sections = {}; 42 43 // Create container 44 this.container = document.createElement('div'); 45 this.container.classList.add('accordion-container'); 46 47 // Apply styles 48 const style = document.createElement('style'); 49 style.textContent = ` 50 .accordion-container { 51 font-family: 'Plus Jakarta Sans', 'Roboto', sans-serif; 52 } 53 54 .global-select-all { 55 gap: 8px; 56 margin-bottom: 16px; 57 display: flex; 58 align-items: center; 59 cursor: pointer; 60 } 61 62 .section { 63 margin-bottom: 12px; 64 overflow: hidden; 65 background: white; 66 } 67 68 .section-header { 69 cursor: pointer; 70 gap: 8px; 71 display: flex; 72 align-items: center; 73 user-select: none; 74 transition: background 0.2s; 75 } 76 77 /* Visible focus ring for keyboard users when toggling accordion sections */ 78 .section-header:focus-visible { 79 outline: 2px solid #0389A0; 80 outline-offset: 2px; 81 border-radius: 4px; 82 box-shadow: 0 0 0 4px rgba(3, 137, 160, 0.2); 83 } 84 85 .section-header-left { 86 display: flex; 87 align-items: center; 88 } 89 90 .section-title { 91 font-weight: 400; 92 font-size: 14px; 93 color: #333; 94 } 95 96 .toggle-icon { 97 position: relative; 98 width: 16px; 99 height: 16px; 100 display: inline-block; 101 transition: transform 0.3s ease; 102 } 103 104 .toggle-icon::before { 105 content: ''; 106 position: absolute; 107 left: 50%; 108 top: 50%; 109 transform: translate(-50%, -50%) rotate(45deg); 110 width: 6px; 111 height: 6px; 112 border-right: 2px solid #3F4242; 113 border-bottom: 2px solid #3F4242; 114 } 115 116 .toggle-icon.expanded { 117 transform: rotate(180deg); 118 } 119 120 .section-content { 121 max-height: 0; 122 overflow: hidden; 123 transition: max-height 0.3s ease-out; 124 } 125 126 .section-content.show { 127 max-height: 1000px; 128 overflow-y: auto; 129 transition: max-height 0.5s ease-in; 130 } 131 132 .section-content-inner { 133 padding: 16px; 134 } 135 136 .checkbox-grid { 137 display: grid; 138 grid-template-columns: repeat(var(--columns, 3), 1fr); 139 gap: 12px; 140 } 141 142 .checkbox-item.special-checkbox-item { 143 grid-column: 1 / -1; 144 display: block; 145 flex-direction: column; 146 gap: 4px; 147 } 148 149 .special-checkbox-description { 150 font-size: 12px; 151 color: #666; 152 line-height: 1.4; 153 margin-left: 30px; 154 } 155 156 .checkbox-grid:has(.special-checkbox-item) { 157 grid-template-columns: 1fr; 158 } 159 160 @media (max-width: 768px) { 161 .checkbox-grid { 162 grid-template-columns: 1fr; 163 } 164 } 165 166 .checkbox-item { 167 display: flex; 168 align-items: flex-start; 169 gap: 8px; 170 cursor: pointer; 171 } 172 173 .checkbox-input { 174 cursor: pointer; 175 width: 100%; 176 } 177 178 179 180 181 182 `; 183 184 this.shadowRoot.appendChild(style); 185 this.shadowRoot.appendChild(this.container); 186 } 187 188 connectedCallback() { 189 this.columns = this.getAttribute('columns') || '3'; 190 this.container.style.setProperty('--columns', this.columns); 191 this.render(); 192 } 193 194 static get observedAttributes() { 195 return ['name', 'show-select-all', 'columns', 'default-open']; 196 } 197 198 attributeChangedCallback(name, oldValue, newValue) { 199 if (name === 'columns' && newValue !== oldValue) { 200 this.columns = newValue; 201 this.container.style.setProperty('--columns', this.columns); 202 } 203 } 204 205 setData(data, selectAll = false) { 206 this.sections = data; 207 this.render(); 208 209 if (selectAll) { 210 setTimeout(() => { 211 this.handleGlobalSelectAll(true); 212 }, 0); 213 } 214 } 215 216 render() { 217 const name = this.getAttribute('name') || 'checkbox'; 218 const showSelectAll = this.hasAttribute('show-select-all'); 219 220 this.container.innerHTML = ''; 221 222 // Global select all 223 if (showSelectAll) { 224 const globalSelectAll = this.createGlobalSelectAll(); 225 this.container.appendChild(globalSelectAll); 226 } 227 228 // Render sections
229 Object.entries(this.sections).forEach(([sectionTitle, items]) => { 230 const section = this.createSection(sectionTitle, items, name); 231 this.container.appendChild(section); 232 }); 233 } 234 235 createGlobalSelectAll() { 236 const wrapper = document.createElement('div'); 237 wrapper.classList.add('global-select-all'); 238 239 const checkbox = document.createElement('checkbox-element'); 240 checkbox.classList.add('global-checkbox'); 241 checkbox.setAttribute('label', 'Select all'); 242 checkbox.style.setProperty('--label-font-size', '14px'); 243 checkbox.style.setProperty('--label-font-weight', '500'); 244 245 checkbox.addEventListener('change', () => { 246 this.handleGlobalSelectAll(checkbox.isChecked()); 247 }); 248 249 wrapper.appendChild(checkbox); 250 251 return wrapper; 252 } 253 254 createSection(title, items, name) { 255 const section = document.createElement('div'); 256 section.classList.add('section'); 257 258 // Header 259 const header = document.createElement('div'); 260 header.classList.add('section-header'); 261 // Keyboard accessibility: header is a toggle button reachable via Tab. 262 // The inner checkbox-element remains independently focusable. 263 header.setAttribute('tabindex', '0'); 264 header.setAttribute('role', 'button'); 265 header.setAttribute('aria-expanded', this.hasAttribute('default-open') ? 'true' : 'false'); 266 267 const headerLeft = document.createElement('div'); 268 headerLeft.classList.add('section-header-left'); 269 270 // Section checkbox 271 const sectionCheckbox = document.createElement('checkbox-element'); 272 sectionCheckbox.classList.add('section-checkbox'); 273 sectionCheckbox.dataset.section = title; 274 sectionCheckbox.setAttribute('label', ''); 275 276 sectionCheckbox.addEventListener('change', (e) => { 277 e.stopPropagation(); 278 this.handleSectionSelectAll(title, sectionCheckbox.isChecked()); 279 }); 280 281 const sectionTitle = document.createElement('span'); 282 sectionTitle.classList.add('section-title'); 283 sectionTitle.textContent = title; 284 285 headerLeft.appendChild(sectionCheckbox); 286 headerLeft.appendChild(sectionTitle); 287 288 // Toggle icon 289 const toggleIcon = document.createElement('span'); 290 toggleIcon.classList.add('toggle-icon'); 291 292 header.appendChild(headerLeft); 293 header.appendChild(toggleIcon); 294 295 // Content 296 const content = document.createElement('div'); 297 content.classList.add('section-content'); 298 if (this.hasAttribute('default-open')) { 299 content.classList.add('show'); 300 toggleIcon.classList.add('expanded'); 301 } 302 303 const contentInner = document.createElement('div'); 304 contentInner.classList.add('section-content-inner'); 305 306 const grid = document.createElement('div'); 307 grid.classList.add('checkbox-grid'); 308 309 // Create checkboxes 310 items.forEach(item => { 311 const checkboxItem = this.createCheckboxItem(item, name, title); 312 grid.appendChild(checkboxItem); 313 }); 314 315 contentInner.appendChild(grid); 316 content.appendChild(contentInner); 317 318 section.appendChild(header); 319 section.appendChild(content); 320 321 // Toggle expand/collapse 322 const toggleSection = () => { 323 const willShow = !content.classList.contains('show'); 324 content.classList.toggle('show', willShow); 325 header.classList.toggle('expanded', willShow); 326 toggleIcon.classList.toggle('expanded', willShow); 327 header.setAttribute('aria-expanded', willShow ? 'true' : 'false'); 328 }; 329 330 header.addEventListener('click', (e) => { 331 if (e.target.tagName.toLowerCase() === 'checkbox-element') return; 332 toggleSection(); 333 }); 334 335 // Keyboard: Enter/Space on the header toggles the section, mirroring click 336 header.addEventListener('keydown', (e) => { 337 if (e.target.tagName.toLowerCase() === 'checkbox-element') return; 338 if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { 339 e.preventDefault(); 340 toggleSection(); 341 } 342 }); 343 344 return section; 345 } 346 347 createCheckboxItem(item, name, section) { 348 const wrapper = document.createElement('div'); 349 wrapper.classList.add('checkbox-item'); 350 351 if (item.special) wrapper.classList.add('special-checkbox-item'); // Add special class if needed 352 353 const checkbox = document.createElement('checkbox-element'); 354 checkbox.classList.add('checkbox-input'); 355 checkbox.setAttribute('label', item.label); 356 checkbox.setAttribute('value', item.value); 357 checkbox.dataset.section = section; 358 359 // Add data attributes 360 if (item.dataAttributes) {
361 Object.entries(item.dataAttributes).forEach(([key, value]) => { 362 checkbox.dataset[key] = value; 363 }); 364 } 365 366 // Mark as special checkbox 367 if (item.special) checkbox.dataset.special = 'true'; 368 369 checkbox.style.setProperty('--label-font-size', '14px'); 370 checkbox.style.setProperty('--label-font-weight', '500'); 371 372 if (item.checked) { 373 checkbox.setAttribute('checked', ''); 374 } 375 376 checkbox.addEventListener('change', () => { 377 // Handle special checkbox separately 378 if (item.special) this.handleMergeCheckboxChange(item.value, checkbox.isChecked()); 379 380 this.updateSectionCheckbox(section); 381 this.updateGlobalCheckbox(); 382 this.syncToForm(); 383 this.dispatchChangeEvent(); 384 }); 385 386 wrapper.appendChild(checkbox); 387 388 // Add description text for special checkboxes 389 if (item.special && item.description) { 390 const description = document.createElement('div'); 391 description.classList.add('special-checkbox-description'); 392 description.textContent = item.description; 393 wrapper.appendChild(description); 394 } 395 396 return wrapper; 397 } 398 399 handleMergeCheckboxChange(mergeValue, isChecked) { 400 const form = this.closest('form'); 401 if (!form) return; 402 403 const inputName = `export[${mergeValue}]`; 404 405 // Use find() instead of querySelector to avoid CSS selector issues with brackets in attribute values 406 const existing = Array.from(form.querySelectorAll('input[type="hidden"]')).find(el => el.name === inputName); 407 if (existing) existing.remove(); 408 409 if (isChecked) { 410 const input = document.createElement('input'); 411 input.type = 'hidden'; 412 input.name = inputName; 413 input.value = '1'; 414 input.dataset.mergeOption = 'true'; 415 form.appendChild(input); 416 } 417 } 418 419 syncToForm() { 420 const form = this.closest('form'); 421 if (!form) return; 422 423 const name = this.getAttribute('name') || 'checkbox'; 424 425 const oldInputs = form.querySelectorAll(`input[data-accordion-sync="${name}"]`); 426 oldInputs.forEach(input => input.remove()); 427 428 const checkedBoxes = this.shadowRoot.querySelectorAll('.checkbox-input'); 429 checkedBoxes.forEach(cb => { 430 if (cb.dataset.special === 'true') { 431 this.handleMergeCheckboxChange(cb.getAttribute('value'), cb.isChecked()); 432 return; 433 } 434 if (!cb.isChecked()) return; 435 const hiddenInput = document.createElement('input'); 436 hiddenInput.type = 'hidden'; 437 hiddenInput.name = name + '[]'; 438 hiddenInput.value = cb.getAttribute('value'); 439 hiddenInput.dataset.accordionSync = name; 440 form.appendChild(hiddenInput); 441 }); 442 } 443 444 handleGlobalSelectAll(checked) { 445 const allCheckboxes = this.shadowRoot.querySelectorAll('.checkbox-input'); 446 allCheckboxes.forEach(cb => { 447 if (cb.dataset.special !== 'true') { 448 cb.checked = checked; 449 } else { 450 cb.checked = checked; 451 this.handleMergeCheckboxChange(cb.getAttribute('value'), checked); 452 } 453 }); 454 455 const allSectionCheckboxes = this.shadowRoot.querySelectorAll('.section-checkbox'); 456 allSectionCheckboxes.forEach(cb => { 457 cb.checked = checked; 458 }); 459 460 const globalCheckbox = this.shadowRoot.querySelector('.global-checkbox'); 461 if (globalCheckbox) { 462 globalCheckbox.checked = checked; 463 globalCheckbox.removeAttribute('indeterminate'); 464 } 465 466 this.syncToForm(); 467 this.dispatchChangeEvent(); 468 } 469 470 handleSectionSelectAll(section, checked) { 471 const sectionCheckboxes = this.shadowRoot.querySelectorAll(`.checkbox-input[data-section="${section}"]`); 472 sectionCheckboxes.forEach(cb => { 473 if (cb.dataset.special !== 'true') { 474 cb.checked = checked; 475 } else { 476 cb.checked = checked; 477 this.handleMergeCheckboxChange(cb.getAttribute('value'), checked); 478 } 479 }); 480 481 // Remove indeterminate state when explicitly checking/unchecking 482 const sectionCheckbox = this.shadowRoot.querySelector(`.section-checkbox[data-section="${section}"]`); 483 if (sectionCheckbox) { 484 sectionCheckbox.removeAttribute('indeterminate'); 485 } 486 487 this.syncToForm(); 488 this.updateGlobalCheckbox(); 489 this.dispatchChangeEvent(); 490 } 491 492 updateSectionCheckbox(section) { 493 const sectionCheckboxes = Array.from( 494 this.shadowRoot.querySelectorAll(`.checkbox-input[data-section="${section}"]`) 495 ).filter(cb => cb.dataset.special !== 'true'); 496 497 const sectionCheckbox = this.shadowRoot.querySelector(`.section-checkbox[data-section="${section}"]`); 498 499 if (sectionCheckbox) { 500 const checkedCount = sectionCheckboxes.filter(cb => cb.isChecked()).length; 501 const totalCount = sectionCheckboxes.length; 502 503 if (checkedCount === 0) { 504 // no selection under it - uncheck and remove indeterminate 505 sectionCheckbox.checked = false;
506 sectionCheckbox.removeAttribute('indeterminate'); 507 } else if (checkedCount === totalCount) { 508 // all selected - check and remove indeterminate 509 sectionCheckbox.checked = true; 510 sectionCheckbox.removeAttribute('indeterminate'); 511 } else { 512 // partially selected - set indeterminate state 513 sectionCheckbox.checked = false; 514 sectionCheckbox.setAttribute('indeterminate', ''); 515 } 516 } 517 } 518 519 updateGlobalCheckbox() { 520 const globalCheckbox = this.shadowRoot.querySelector('.global-checkbox'); 521 if (!globalCheckbox) return; 522 523 const allCheckboxes = Array.from( 524 this.shadowRoot.querySelectorAll('.checkbox-input') 525 ).filter(cb => cb.dataset.special !== 'true'); 526 527 if (allCheckboxes.length === 0) return; 528 529 const checkedCount = allCheckboxes.filter(cb => cb.isChecked()).length; 530 const totalCount = allCheckboxes.length; 531 532 if (checkedCount === 0) { 533 // no selection 534 globalCheckbox.checked = false; 535 globalCheckbox.removeAttribute('indeterminate'); 536 } else if (checkedCount === totalCount) { 537 // all selected 538 globalCheckbox.checked = true; 539 globalCheckbox.removeAttribute('indeterminate'); 540 } else { 541 // partially selected 542 globalCheckbox.checked = false; 543 globalCheckbox.setAttribute('indeterminate', ''); 544 } 545 } 546 547 dispatchChangeEvent() { 548 this.dispatchEvent(new CustomEvent('change', { 549 detail: { values: this.getData() }, 550 bubbles: true, 551 composed: true 552 })); 553 } 554 555 556 filterByApps(selectedAppIds) { 557 const allQuestions = this.shadowRoot.querySelectorAll('.checkbox-input'); 558 559 allQuestions.forEach(checkbox => { 560 const appId = checkbox.dataset.app; 561 const checkboxItem = checkbox.closest('.checkbox-item'); 562 563 if (appId) { 564 if (selectedAppIds.includes(appId)) { 565 checkboxItem.style.display = ''; 566 checkbox.removeAttribute('disabled'); 567 checkbox.checked = true; 568 } else { 569 checkboxItem.style.display = 'none'; 570 checkbox.checked = false; 571 checkbox.setAttribute('disabled', ''); 572 } 573 } 574 }); 575 576 // update section visibility 577 Object.keys(this.sections).forEach(sectionTitle => { 578 this.updateSectionVisibility(sectionTitle); 579 }); 580 581 this.syncToForm(); 582 this.updateGlobalCheckbox(); 583 } 584 585 updateSectionVisibility(sectionTitle) { 586 const section = Array.from(this.shadowRoot.querySelectorAll('.section')) 587 .find(s => s.querySelector('.section-title').textContent === sectionTitle); 588 589 if (!section) return; 590 591 const visibleItems = Array.from( 592 section.querySelectorAll('.checkbox-item') 593 ).filter(item => item.style.display !== 'none'); 594 595 section.style.display = visibleItems.length === 0 ? 'none' : ''; 596 597 if (visibleItems.length > 0) { 598 this.updateSectionCheckbox(sectionTitle); 599 } 600 } 601 602 getData() { 603 const checkedBoxes = this.shadowRoot.querySelectorAll('.checkbox-input'); 604 return Array.from(checkedBoxes) 605 .filter(cb => cb.isChecked()) 606 .map(cb => cb.getAttribute('value')); 607 } 608 609 reset() { 610 const allCheckboxes = this.shadowRoot.querySelectorAll('checkbox-element'); 611 allCheckboxes.forEach(cb => { 612 cb.checked = false; 613 }); 614 this.dispatchChangeEvent(); 615 } 616 617 isDisabled() { 618 return this.hasAttribute('disabled'); 619 } 620} 621 622// Define the custom element 623if (!customElements.get('accordion-checkbox-element')) { 624 customElements.define('accordion-checkbox-element', AccordionCheckboxElement); 625}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.