1/** 2 * _ _ ___ __ __ 3 * | | | |( _ )\ \ / / 4 * | |_| |/ _ \/\ \ /\ / / 5 * | _ | (_> <\ V V / 6 * |_| |_|\___/\/ \_/\_/ 7 * 8 * @file Details.js 9 * @description Provides Web Components that enhance native <details> with 10 * smooth open/close animations, grouping, optional accordion behavior, and a 11 * toggleâall action. Behavior is driven by namespaced custom elements and 12 * dedicated data attributes, keeping markup declarative and consistent. 13 * 14 * Custom elements: 15 * - `hw13-details-group`: wraps a group of elements; accordion behavior via 16 * `data-accordion` attribute. Scoping is by DOM hierarchy, not by name. 17 * - `hw13-details` (default, with `data-name`) or `hw13-details-${name}` 18 * (custom): wraps a single <details> element and tracks its open state. 19 * The animated content lives inside `hw13-details-content`. 20 * 21 * Attributes: 22 * - `data-accordion`: on group â allow only one element open at a time. 23 * - `data-open`: per-element open state ('true' | 'false'). 24 * - `data-name`: namespace identifier on group and default-mode elements. 25 * - `data-toggle-all`: set internally to toggle all in a group. 26 * 27 * Example usage: 28 * ``` 29 * <hw13-details-group data-name="faq" data-accordion> 30 * <hw-button><button>Toggle all</button></hw-button> 31 * 32 * <hw13-details data-name="faq" data-open="false"> 33 * <details> 34 * <summary> 35 * <hw13-details-summary>Details Title</hw13-details-summary> 36 * </summary> 37 * <hw13-details-content> 38 * <hw13-details-content-container> 39 * <p>Content here.</p> 40 * </hw13-details-content-container> 41 * </hw13-details-content> 42 * </details> 43 * <hw13-details-state-icons> 44 * <hw13-icon data-state-icon="closed" /> 45 * <hw13-icon data-state-icon="open" /> 46 * </hw13-details-state-icons> 47 * </hw13-details> 48 * </hw13-details-group> 49 * ``` 50 * 51 * Setup: 52 * - Import this module with a `?name=faq` parameter, or call `hw13DetailsSetup('faq')`. 53 * - If no name is provided, the module reads `?name=` from its own import URL. 54 * 55 * @param {string} name Optional namespace for the custom-mode element tag 56 * (e.g., 'faq' -> `hw13-details-faq`). 57 * When omitted, the value is read from the module import URL search param `name`. 58 */ 59 60// --- Extend HTMLElement to get a callback, if a defined data attribute was changed 61function defineElement(name) { 62 class hw13DetailsElement extends HTMLElement { 63 static openAttribute = 'data-open'; 64 static attributes = { open: hw13DetailsElement.openAttribute }; 65 static observedAttributes = Object.values(hw13DetailsElement.attributes); 66 67 constructor() { 68 super(); 69 70 this.openAttribute = hw13DetailsElement.openAttribute; 71 } 72 73 // DOM-dependent setup â must run in connectedCallback, not constructor, 74 // because closest() and querySelector() need the element to be in the DOM tree. 75 _initDom() { 76 if (this._domInitialized) return; 77 this._domInitialized = true; 78 79 this.openState = this.getAttribute(this.openAttribute); 80 81 // Find the closest group by DOM hierarchy (no name matching needed) 82 this.group = this.closest('hw13-details-group'); 83 this.element = this.querySelector(':scope > details'); 84 this.summary = this.element?.querySelector(':scope > summary'); 85 this.content = this.element?.querySelector(':scope > hw13-details-content'); 86 this.container = this.content?.querySelector(':scope > hw13-details-content-container'); 87 88 if (this.summary) { 89 this._boundOnClick = this._onClick.bind(this); 90 this.summary.addEventListener('click', this._boundOnClick); 91 } 92 } 93 94 _onClick(event) { 95 event.preventDefault(); 96 97 // If the group is an accordion, close other open elements in the same group 98 const state = this.getAttribute(this.openAttribute) !== 'true'; 99 100 if (this.group && this.group.isAccordionGroup(this.group) && state) { 101 const groupElements = this.group.allElements.filter( 102 (element) => this.group.findGroupForElement(element) === this.group, 103 ); 104 105 for (const otherElement of groupElements) { 106 if ( 107 otherElement !== this && 108 otherElement.getAttribute(this.openAttribute) === 'true' 109 ) { 110 otherElement.setElementOpenState(false); 111 } 112 } 113 } 114 115 // Toggle the open state of the clicked <details> element 116 this.setElementOpenState(state); 117 } 118 119 // Function to set the open/closed state based on a given boolean 120 setElementOpenState(state) { 121 const nextState = state ? 'true' : 'false'; 122 123 // Only update the attribute if it is different from the current state 124 if (this.getElementOpenState() !== nextState) { 125 // This triggers the attributeChangedCallback 126 this.setAttribute(this.openAttribute, nextState); 127 } 128 } 129 130 getElementOpenState() { 131 return this.getAttribute(this.openAttribute); 132 } 133
134 // Function that queues mutations and ensures efficient processing via requestAnimationFrame 135 queueMutationForElement() { 136 elementsToMutate.add(this); 137 138 // If there's already a requestAnimationFrame pending, skip adding a new one 139 if (isRequestAnimationFramePending) { 140 return; 141 } 142 143 // Mark that a requestAnimationFrame is pending 144 isRequestAnimationFramePending = true; 145 146 // This callback will be called on the next animation frame, processing all queued mutations 147 requestAnimationFrame(() => { 148 // Reset the flag after the animation frame callback runs 149 isRequestAnimationFramePending = false; 150 151 // Perform the mutation (typically opening/closing a details element) 152 for (const wrapper of elementsToMutate) { 153 // Trigger animation to open or close the details element 154 if (wrapper.openState === 'true') { 155 wrapper.animateElementOpen(); 156 } else { 157 wrapper.animateElementClose(); 158 } 159 } 160 161 // Clear the set after processing all mutations 162 elementsToMutate.clear(); 163 }); 164 } 165 166 // --- Helper Functions for Animations --- 167 // Check if the user prefers reduced motion 168 prefersReducedMotion() { 169 return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; 170 } 171 172 // Cancel the current animation if one is running 173 cancelCurrentAnimation() { 174 // Cancel the ongoing animation if present 175 this.element.elementAnimation?.cancel?.(); 176 177 // Reset the animation reference 178 this.element.elementAnimation = null; 179 } 180 181 // Apply accordion behavior to close other open details elements in the same group when one is opened 182 applyAccordionBehaviorForElement() { 183 // If the group is not an accordion, or the current details element is not open, exit 184 if ( 185 !this.group || 186 !this.group.isAccordionGroup(this.group) || 187 this.getElementOpenState() !== 'true' 188 ) 189 return; 190 191 // Close other open details elements in the same group 192 for (const otherElement of this.group.allElements) { 193 if ( 194 otherElement !== this && 195 otherElement.getAttribute(otherElement.openAttribute) === 'true' 196 ) { 197 otherElement.setElementOpenState(false); 198 } 199 } 200 } 201 202 // Animate the opening of the <details> element by expanding the content to its full height 203 animateElementOpen() { 204 const style = getComputedStyle(this); 205 const duration = 206 parseFloat(style.getPropertyValue('--hw13-motion-duration-normal')) || 200; 207 const easing = 208 style.getPropertyValue('--hw13-motion-timing-function').trim() || 209 'cubic-bezier(.2,0,.2,1)'; 210 211 // Cancel any ongoing animation 212 this.cancelCurrentAnimation(); 213 214 // Make the target element visible 215 this.element.setAttribute('open', ''); 216 this.openState = true; 217 218 // Force reflow so the browser calculates layout after open 219 this.element.getBoundingClientRect(); 220 221 // Prevent overflow during the animation 222 this.content.style.overflow = 'hidden'; 223 224 // Calculate the final height (scrollHeight of the content) 225 this.content.style.height = 'auto'; 226 const contentEndHeight = this.content.scrollHeight; 227 228 // If reduced motion is preferred or the height is already 0, set it to auto immediately 229 if (this.prefersReducedMotion() || contentEndHeight === 0) { 230 this.content.style.height = 'auto'; 231 this.content.style.overflow = ''; 232 233 return Promise.resolve(); 234 } 235 236 // Set the starting height to 0rem and force a reflow 237 this.content.style.height = '0rem'; 238 this.content.getBoundingClientRect(); 239 240 requestAnimationFrame(() => { 241 // Start the animation to expand the content to its full height 242 const rootFontSize = 243 parseInt( 244 getComputedStyle(document.body).getPropertyValue('--hw13-root-font-size'), 245 10, 246 ) || 16; 247 const animation = this.content.animate( 248 [ 249 { 250 height: '0rem', 251 }, 252 { 253 height: `${contentEndHeight / rootFontSize}rem`, 254 }, 255 ], 256 { 257 duration, 258 easing, 259 fill: 'forwards', 260 }, 261 ); 262 263 // Store the animation reference for later cancellation 264 this.element.elementAnimation = animation; 265 266 // Fade in the inner container 267 if (this.container) { 268 this.container.animate( 269 [ 270 { opacity: 0, transform: 'translateY(0.5rem)' }, 271 { opacity: 1, transform: 'translateY(0)' }, 272 ], 273 { duration, easing }, 274 ); 275 } 276 277 // Fade in nested state icons (inside the revealed content) 278 for (const icons of this.content.querySelectorAll('hw13-details-state-icons')) { 279 icons.animate( 280 [ 281 { opacity: 0, transform: 'translateY(0.5rem)' }, 282 { opacity: 1, transform: 'translateY(0)' }, 283 ], 284 { duration, easing }, 285 ); 286 } 287 288 return animation.finished 289 .then(() => { 290 // Cancel animation to remove fill: 'forwards' effect 291 // This allows the element to grow when nested details open 292 animation.cancel(); 293 this.element.elementAnimation = null; 294 this.content.style.height = 'auto'; 295 this.content.style.overflow = ''; 296 }) 297 .catch(() => { 298 /* Animation was canceled */ 299 }); 300 }); 301 } 302
303 // Animate the closing of the <details> element by collapsing the content to 0rem 304 animateElementClose() { 305 const style = getComputedStyle(this); 306 const duration = 307 parseFloat(style.getPropertyValue('--hw13-motion-duration-normal')) || 200; 308 const easing = 309 style.getPropertyValue('--hw13-motion-timing-function').trim() || 310 'cubic-bezier(.2,0,.2,1)'; 311 312 // Cancel any ongoing animation 313 this.cancelCurrentAnimation(); 314 315 // Prevent overflow during the animation 316 this.content.style.overflow = 'hidden'; 317 318 // Get the current height of the content (it may be 'auto' or a fixed value) 319 this.content.style.height = 'auto'; 320 const contentStartHeight = this.content.scrollHeight; 321 322 // If the height is already 0 or reduced motion is preferred, immediately collapse it 323 324 if (this.prefersReducedMotion() || contentStartHeight === 0) { 325 this.element.removeAttribute('open'); 326 this.content.style.height = ''; 327 this.content.style.overflow = ''; 328 329 return Promise.resolve(); 330 } 331 332 // Set the starting height and force a reflow 333 const rootFontSize = 334 parseInt( 335 getComputedStyle(document.body).getPropertyValue('--hw13-root-font-size'), 336 10, 337 ) || 16; 338 339 this.content.style.height = `${contentStartHeight / rootFontSize}rem`; 340 this.content.getBoundingClientRect(); 341 342 requestAnimationFrame(() => { 343 // Start the animation to collapse the content to 0rem 344 const animation = this.content.animate( 345 [ 346 { 347 height: `${contentStartHeight / rootFontSize}rem`, 348 }, 349 { 350 height: '0rem', 351 }, 352 ], 353 { 354 duration, 355 easing, 356 fill: 'forwards', 357 }, 358 ); 359 360 this.element.elementAnimation = animation; 361 362 // Fade out the inner container 363 if (this.container) { 364 this.container.animate( 365 [ 366 { opacity: 1, transform: 'translateY(0)' }, 367 { opacity: 0, transform: 'translateY(0.5rem)' }, 368 ], 369 { duration, easing }, 370 ); 371 } 372 373 // Fade out nested state icons (inside the collapsing content) 374 for (const icons of this.content.querySelectorAll('hw13-details-state-icons')) { 375 icons.animate( 376 [ 377 { opacity: 1, transform: 'translateY(0)' }, 378 { opacity: 0, transform: 'translateY(0.5rem)' }, 379 ], 380 { duration, easing }, 381 ); 382 } 383 384 return animation.finished 385 .then(() => { 386 // Cancel animation to remove fill: 'forwards' effect 387 animation.cancel(); 388 this.element.elementAnimation = null; 389 this.element.removeAttribute('open'); 390 this.openState = false; 391 this.content.style.height = ''; 392 this.content.style.overflow = ''; 393 }) 394 .catch(() => { 395 /* Animation was canceled */ 396 }); 397 }); 398 } 399 400 connectedCallback() { 401 this._initDom(); 402 403 this.openState = this.getAttribute(this.openAttribute); 404 405 // Guard against missing optional dialog element before reading `.open` 406 const dialog = this.querySelector(':scope > dialog'); 407 const isDialogOpen = !!(dialog && dialog.open); 408 409 if (this.getAttribute(this.openAttribute) === 'true' && !isDialogOpen) { 410 this.animateElementOpen(); 411 } 412 } 413 414 attributeChangedCallback(attr, oldValue, newValue) { 415 if (oldValue !== null) { 416 // Determine the desired open state 417 this.openState = this.getAttribute(this.openAttribute); 418 419 this.applyAccordionBehaviorForElement(); 420 421 this.queueMutationForElement(); 422 423 // Update toggle-all button label â check own group first, 424 // then find any group whose button targets this element's UID 425 if (this.group?.updateToggleButtonLabel) { 426 this.group.updateToggleButtonLabel(); 427 } 428 const detailsId = this.querySelector(':scope > details')?.id || ''; 429 if (detailsId) { 430 const uid = detailsId.replace('hw13_slideblock_c', ''); 431 for (const btn of document.querySelectorAll('button[data-sli
431deblock-uids]')) { 432 if (btn.dataset.slideblockUids.split(',').map((s) => s.trim()).includes(uid)) { 433 btn.closest('hw13-details-group')?.updateToggleButtonLabel?.(); 434 } 435 } 436 } 437 } 438 } 439 } 440 441 // Define hw13-details (default mode, shared across all names) 442 if (!customElements.get('hw13-details')) { 443 customElements.define('hw13-details', hw13DetailsElement); 444 } 445 446 // Define hw13-details-${name} (custom mode) 447 if (name && !customElements.get(`hw13-details-${name}`)) { 448 customElements.define(`hw13-details-${name}`, class extends hw13DetailsElement {}); 449 } 450} 451 452function defineGroup(name) { 453 class hw13DetailsGroup extends HTMLElement { 454 static toggleAllAttribute = 'data-toggle-all'; 455 static attributes = { toggleAll: hw13DetailsGroup.toggleAllAttribute }; 456 static observedAttributes = Object.values(hw13DetailsGroup.attributes); 457 458 constructor() { 459 super(); 460 461 this.toggleAllAttribute = hw13DetailsGroup.toggleAllAttribute; 462 this.accordionAttribute = 'data-accordion'; 463 } 464 465 // Function to find the closest group element for a given <details> element 466 findGroupForElement(element) { 467 return element.closest('hw13-details-group'); 468 } 469 470 // Function to determine if a given group element has the accordion attribute 471 isAccordionGroup(group) { 472 return !!group?.hasAttribute(this.accordionAttribute); 473 } 474 475 // Get all details elements in this group (scoped by DOM hierarchy, not name) 476 get allElements() { 477 return Array.from(this.querySelectorAll('[data-open]')).filter( 478 (el) => 479 typeof el.setElementOpenState === 'function' && 480 !el.hasAttribute('data-hw13-components-details-container'), 481 ); 482 } 483 484 // Initial correction for accordion behavior: ensures that only one <details> element is open at a time in the group 485 enforceInitialAccordionState(group) { 486 if (!this.isAccordionGroup(group)) return; 487 488 const elements = this.allElements.filter( 489 (element) => this.findGroupForElement(element) === group, 490 ); 491 const firstOpenElement = elements.find( 492 (element) => element.getAttribute(element.openAttribute) === 'true', 493 ); 494 495 if (!firstOpenElement) return; 496 497 // Close all other open details elements in the same group 498 for (const element of elements) { 499 if ( 500 element !== firstOpenElement && 501 element.getAttribute(element.openAttribute) === 'true' 502 ) { 503 element.setElementOpenState(false); 504 } 505 } 506 } 507 508 // Resolve all slideblock detail elements for this group's toggle button. 509 // If the button has data-slideblock-uids, find elements across the page b
509y UID. 510 // Otherwise fall back to elements within this group's DOM. 511 _getToggleTargets() { 512 const uids = this._toggleButton?.dataset.slideblockUids; 513 if (uids) { 514 const selector = uids.split(',').map((uid) => `#hw13_slideblock_c${uid.trim()}`).join(','); 515 return Array.from(document.querySelectorAll(selector)) 516 .map((details) => details.closest('[data-open]')) 517 .filter((el) => el && typeof el.setElementOpenState === 'function'); 518 } 519 return this.allElements.filter( 520 (element) => this.findGroupForElement(element) === this, 521 ); 522 } 523 524 // Setup for the "toggle all" button in the group, if applicable 525 setupGroupToggleButton() { 526 // Look for the button wrapper in the group 527 const toggleButtonWrap = this.querySelector?.(':scope > hw13-button, :scope > hw-button'); 528 529 // Find the button itself 530 const toggleButton = toggleButtonWrap?.querySelector('button'); 531 532 // If no button is found, exit 533 if (!toggleButton) return; 534 535 // Store reference for label updates 536 this._toggleButton = toggleButton; 537 538 // Sync label with actual state (e.g. when slideblockFirstOpen is active) 539 this.updateToggleButtonLabel(); 540 541 // Attach an event listener to the button 542 toggleButton.addEventListener('click', (event) => { 543 event.preventDefault(); 544 545 const targets = this._getToggleTargets(); 546 const anyOpen = targets.some( 547 (el) => el.getAttribute(el.openAttribute) === 'true', 548 ); 549 550 if (anyOpen) { 551 targets.forEach((el) => el.setElementOpenState(false)); 552 } else { 553 targets.forEach((el) => el.setElementOpenState(true)); 554 } 555 556 this.updateToggleButtonLabel(); 557 }); 558 } 559 560 // Update toggle button text based on current group state 561 updateToggleButtonLabel() { 562 const btn = this._toggleButton; 563 if (!btn || !btn.dataset.labelOpen) return; 564 565 const targets = this._getToggleTargets(); 566 const anyOpen = targets.some( 567 (el) => el.getAttribute(el.openAttribute) === 'true', 568 ); 569 570 const label = anyOpen ? btn.dataset.labelClose : btn.dataset.labelOpen; 571 const textEl = btn.querySelector('hw13-button-text'); 572 if (textEl) { 573 textEl.textContent = label; 574 } else { 575 btn.textContent = label; 576 } 577 } 578 579 init() { 580 this.enforceInitialAccordionState(this); 581 this.setupGroupToggleButton(); 582 } 583 584 connectedCallback() { 585 // Define child elements first, so they are upgraded before init queries them 586 defineElement(this.dataset.name || name); 587 this.init(); 588 } 589 590 attributeChangedCallback(attr, oldValue, newValue) { 591 if (this.getAttribute(this.toggleAllAttribute) !== '') return; 592 593 if (!this.isAccordionGroup(this)) { 594 const groupElements = this.allElements.filter( 595 (element) => this.findGroupForElement(element) === this, 596 ); 597 598 if (groupElements.length) { 599 const areAllElementsOpen = groupElements.every( 600 (element) => element.getAttribute(element.openAttribute) === 'true', 601 ); 602 if (areAllElementsOpen) { 603 groupElements.forEach((element) => element.setElementOpenState(false)); 604 } else { 605 groupElements.forEach((element) => element.setElementOpenState(true)); 606 } 607 } 608 } 609 610 // Remove the toggle-all attribute and update button label 611 requestAnimationFrame(() => { 612 this.removeAttribute(this.toggleAllAttribute); 613 this.updateToggleButtonLabel(); 614 }); 615 } 616 } 617 618 // Only define if not already registered 619 if (!customElements.get('hw13-details-group')) { 620 customElements.define('hw13-details-group', hw13DetailsGroup); 621 } 622} 623 624// Create a set to track elements that need mutation, avoiding multiple reflows 625const elementsToMutate = new Set(); 626 627// This flag ensures that the mutation callback only runs once per frame 628let isRequestAnimationFramePending = false; 629 630export function hw13DetailsSetup(name) { 631 if (name) { 632 // Single name provided â define that one 633 defineGroup(name); 634 defineElement(name); 635 return; 636 } 637 638 // Auto-discover all unique names from data-name attributes in the DOM 639 const names = new Set(); 640 document.querySelectorAll('[data-name]').forEach(el => { 641 const closest = el.closest('hw13-details, hw13-details-group'); 642 if (closest) { 643 names.add(el.dataset.name || closest.dataset.name); 644 } 645 }); 646 // Also check hw13-details elements with data-name directly 647 document.querySelectorAll('hw13-details[data-name], hw13-details-group[data-name]').forEach(el => { 648 names.add(el.dataset.name); 649 }); 650
651 names.forEach(n => { 652 if (n) { 653 defineGroup(n); 654 defineElement(n); 655 } 656 }); 657} 658 659if (document.readyState === 'complete') { 660 hw13DetailsSetup(); 661} else { 662 document.addEventListener('readystatechange', () => { 663 if (document.readyState === 'complete') { 664 hw13DetailsSetup(); 665 } 666 }); 667}
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.