PageSourceSearch

https://www.neustadt-donau.de/_assets/094881798ba18447100691146b2e…/Components/Details/Details.js?1786610008

js neustadt-donau.de collected 2026-10-02 17:53:50 UTC 20,935 bytes, 667 lines download raw bytes

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.