vvja-1.0.1/js/vvja.js
js/vvja.js
/**
* @file
* Views Vanilla JavaScript Accordion.
*
* Filename: vvja.js
* Website: https://www.flashwebcenter.com
* Developer: Alaa Haddad https://www.alaahaddad.com.
*/
((Drupal, drupalSettings, once) => {
'use strict';
Drupal.behaviors.VVJAccordion = {
attach: function (context, settings) {
const accordionContainers = once('vvjAccordion', '.vvja > .vvja-inner', context);
accordionContainers.forEach(container => {
const accordionButtons = container.querySelectorAll('.vvja-button');
once('vvjAccordionButtonInit', accordionButtons).forEach((button) => {
if (!button.classList.contains('active')) {
button.setAttribute('tabindex', '-1');
}
button.addEventListener('click', handleAccordionClick);
});
const globalToggleButton = container.querySelector('.global-toggle .button.group-toggle');
if (globalToggleButton) {
globalToggleButton.addEventListener('click', () => {
const isExpanded = globalToggleButton.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
handleGlobalToggle('collapse');
globalToggleButton.setAttribute('aria-expanded', 'false');
toggleAriaHidden(globalToggleButton.querySelector('.b-plus'), false);
toggleAriaHidden(globalToggleButton.querySelector('.b-minus'), true);
} else {
handleGlobalToggle('expand');
globalToggleButton.setAttribute('aria-expanded', 'true');
toggleAriaHidden(globalToggleButton.querySelector('.b-plus'), true);
toggleAriaHidden(globalToggleButton.querySelector('.b-minus'), false);
}
});
}
const firstToggle = container.dataset.firstToggle === 'true';
if (firstToggle) {
const firstButton = accordionButtons[0];
const firstPane = firstButton.nextElementSibling;
openPane(firstPane);
activateButton(firstButton);
toggleAriaHidden(firstButton.querySelector('.b-plus'), true);
toggleAriaHidden(firstButton.querySelector('.b-minus'), false);
}
function updateGlobalToggleState(container) {
const globalToggleButton = container.querySelector('.global-toggle .group-toggle');
if (!globalToggleButton) return;
const panes = container.querySelectorAll('.vvja-pane');
const allOpen = Array.from(panes).every(pane => pane.getAttribute('aria-hidden') === 'false');
const allClosed = Array.from(panes).every(pane => pane.getAttribute('aria-hidden') === 'true');
if (allOpen) {
globalToggleButton.setAttribute('aria-expanded', 'true');
toggleAriaHidden(globalToggleButton.querySelector('.b-plus'), true);
toggleAriaHidden(globalToggleButton.querySelector('.b-minus'), false);
} else if (allClosed) {
globalToggleButton.setAttribute('aria-expanded', 'false');
toggleAriaHidden(globalToggleButton.querySelector('.b-plus'), false);
toggleAriaHidden(globalToggleButton.querySelector('.b-minus'), true);
} else {
// Mixed state: keep as-is or default to collapse
globalToggleButton.setAttribute('aria-expanded', 'false');
toggleAriaHidden(globalToggleButton.querySelector('.b-plus'), false);
toggleAriaHidden(globalToggleButton.querySelector('.b-minus'), true);
}
}
function openPane(pane) {
const panePadding = 32;
pane.style.maxHeight = `${pane.scrollHeight + panePadding}px`;
pane.style.padding = '16px';
pane.setAttribute('aria-hidden', 'false');
toggleFocusableElements(pane, false);
// Optional: Let it resize naturally afterward
pane.addEventListener('transitionend', () => {
if (pane.getAttribute('aria-hidden') === 'false') {
pane.style.maxHeight = 'none';
}
}, { once: true });
}
function closePane(pane) {
pane.style.maxHeight = `${pane.scrollHeight}px`;
requestAnimationFrame(() => {
pane.style.maxHeight = '0';
pane.style.padding = '0 16px';
});
pane.setAttribute('aria-hidden', 'true');
toggleFocusableElements(pane, true);
}
function deactivateButton(button) {
button.classList.remove('active');
button.setAttribute('tabindex', '-1');
button.setAttribute('aria-expanded', 'false');
updateToggleIndicator(button, false);
button.parentElement.classList.remove('opened');
button.parentElement.classList.add('closed');
}
function activateButton(button) {
button.classList.add('active');
button.removeAttribute('tabindex');
button.setAttribute('aria-expanded', 'true');
updateToggleIndicator(button, true);
button.parentElement.classList.remove('closed');
button.parentElement.classList.add('opened');
}
function updateToggleIndicator(button, isOpen) {
const toggle = button.querySelector('.single-toggle');
if (toggle) {
const plus = toggle.querySelector('.b-plus');
const minus = toggle.querySelector('.b-minus');
if (isOpen) {
toggleAriaHidden(plus, true);
toggleAriaHidden(minus, false);
} else {
toggleAriaHidden(plus, false);
toggleAriaHidden(minus, true);
}
}
}
function toggleAriaHidden(element, isHidden) {
if (element) {
element.setAttribute('aria-hidden', isHidden.toString());
element.classList.toggle('vvja-hidden', isHidden);
}
}
function toggleFocusableElements(pane, shouldDisable) {
const focusableElements = pane.querySelectorAll('a, button, input, select, textarea, [tabindex]');
focusableElements.forEach(element => {
if (shouldDisable) {
element.setAttribute('tabindex', '-1');
element.setAttribute('aria-disabled', 'true');
element.disabled = true;
} else {
element.removeAttribute('tabindex');
element.removeAttribute('aria-disabled');
element.disabled = false;
}
});
}
function handleAccordionClick(event) {
const button = event.currentTarget;
const pane = button.nextElementSibling;
const container = button.closest('.vvja-inner');
const allButtons = container.querySelectorAll('.vvja-button');
const isOpen = pane.style.maxHeight && pane.style.maxHeight !== '0px';
// Check if exclusive mode is enabled via data attribute
const isExclusive = container.dataset.exclusive === 'true';
if (isExclusive) {
// Exclusive mode: close all other panels
allButtons.forEach(otherButton => {
const otherPane = otherButton.nextElementSibling;
if (otherButton !== button) {
closePane(otherPane);
deactivateButton(otherButton);
}
});
if (isOpen) {
closePane(pane);
deactivateButton(button);
} else {
openPane(pane);
activateButton(button);
}
} else {
if (isOpen) {
closePane(pane);
deactivateButton(button);
} else {
openPane(pane);
activateButton(button);
}
updateGlobalToggleState(container);
}
// Update deep link hash if enabled.
updateDeepLinkHash(container, pane);
}
function handleGlobalToggle(action) {
const panes = container.querySelectorAll('.vvja-pane');
const buttons = container.querySelectorAll('.vvja-button');
if (action === 'expand') {
buttons.forEach(button => {
const pane = button.nextElementSibling;
if (pane) {
openPane(pane);
activateButton(button);
}
});
} else if (action === 'collapse') {
panes.forEach(pane => closePane(pane));
buttons.forEach(button => deactivateButton(button));
}
}
function handleKeyboardNavigation(accordionButtons) {
accordionButtons.forEach((button, index) => {
button.addEventListener('keydown', (event) => {
const currentIndex = index;
let newIndex;
switch (event.key) {
case 'ArrowDown':
newIndex = (currentIndex + 1) % accordionButtons.length;
break;
case 'ArrowUp':
newIndex = (currentIndex - 1 + accordionButtons.length) % accordionButtons.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = accordionButtons.length - 1;
break;
default:
return;
}
accordionButtons.forEach(b => b.setAttribute('tabindex', '-1'));
accordionButtons[newIndex].setAttribute('tabindex', '0');
accordionButtons[newIndex].focus();
event.preventDefault();
});
});
}
function updateAriaLabelsForAccordionButtons(accordionButtons) {
accordionButtons.forEach(button => {
const img = button.querySelector('img');
if (img && img.alt) {
button.setAttribute('aria-label', img.alt);
} else if (button.hasAttribute('aria-label')) {
return;
} else {
const labelElement = button.querySelector('.button-label');
if (labelElement) {
button.setAttribute('aria-label', labelElement.textContent.trim());
} else {
button.setAttribute('aria-label', 'Accordion Button');
}
}
});
}
function disableLinksInAccordionButtons(accordionButtons) {
accordionButtons.forEach(button => {
const links = button.querySelectorAll('a');
links.forEach(link => {
link.setAttribute('data-original-href', link.getAttribute('href'));
link.removeAttribute('href');
link.style.cursor = 'pointer';
link.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
});
});
});
}
function initializeFocusableElements() {
const panes = container.querySelectorAll('.vvja-pane');
panes.forEach(pane => {
const isHidden = pane.getAttribute('aria-hidden') === 'true';
toggleFocusableElements(pane, isHidden);
});
}
// Debounce function to limit resize event calls
let resizeTimeout;
let lastResizeWidth = window.innerWidth;
function handleResizeDebounced() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (window.innerWidth > 768 && Math.abs(lastResizeWidth - window.innerWidth) > 30) {
handleResize();
lastResizeWidth = window.innerWidth;
}
}, 100);
}
// Adjust open panes on resize
function handleResize() {
const openPanes = container.querySelectorAll('.vvja-pane[aria-hidden="false"]');
openPanes.forEach(pane => {
const panePadding = 32; // Match the padding used in openPane()
pane.style.maxHeight = `${pane.scrollHeight + panePadding}px`;
});
}
// Add resize event listener
window.addEventListener('resize', handleResizeDebounced);
handleKeyboardNavigation(accordionButtons);
updateAriaLabelsForAccordionButtons(accordionButtons);
disableLinksInAccordionButtons(accordionButtons);
initializeFocusableElements();
// Initialize deep linking if enabled.
const deeplinkEnabled = container.dataset.deeplinkEnabled === 'true';
const deeplinkId = container.dataset.deeplinkId;
if (deeplinkEnabled && deeplinkId) {
initializeDeepLinking(container, deeplinkId);
}
});
}
};
/**
* Initialize deep linking functionality.
*
* @param {HTMLElement} container
* The accordion container element.
* @param {string} deeplinkId
* The deep link identifier.
*/
function initializeDeepLinking(container, deeplinkId) {
// Check URL hash on page load.
const hash = window.location.hash;
if (hash && hash.startsWith(`#accordion-${deeplinkId}-`)) {
const panelNumber = parseInt(hash.split('-').pop(), 10);
if (!isNaN(panelNumber) && panelNumber > 0) {
const buttons = container.querySelectorAll('.vvja-button');
if (panelNumber <= buttons.length) {
setTimeout(() => {
buttons[panelNumber - 1].click();
}, 100);
}
}
}
// Listen for hash changes.
const hashChangeHandler = function() {
const hash = window.location.hash;
if (hash && hash.startsWith(`#accordion-${deeplinkId}-`)) {
const panelNumber = parseInt(hash.split('-').pop(), 10);
if (!isNaN(panelNumber) && panelNumber > 0) {
const buttons = container.querySelectorAll('.vvja-button');
if (panelNumber <= buttons.length) {
buttons[panelNumber - 1].click();
}
}
}
};
window.addEventListener('hashchange', hashChangeHandler);
// Store handler for cleanup.
if (!container.vvjaEventHandlers) {
container.vvjaEventHandlers = {};
}
container.vvjaEventHandlers.hashChange = hashChangeHandler;
}
/**
* Update URL hash when panel is opened.
*
* @param {HTMLElement} container
* The accordion container element.
* @param {HTMLElement} pane
* The pane element.
*/
function updateDeepLinkHash(container, pane) {
const deeplinkEnabled = container.dataset.deeplinkEnabled === 'true';
const deeplinkId = container.dataset.deeplinkId;
if (deeplinkEnabled && deeplinkId && pane) {
const panelIndex = pane.dataset.index;
if (panelIndex) {
const newHash = `#accordion-${deeplinkId}-${panelIndex}`;
if (window.location.hash !== newHash && pane.getAttribute('aria-hidden') === 'false') {
history.replaceState(null, '', newHash);
}
}
}
}
/**
* Public API for external accordion control.
*/
Drupal.vvja = Drupal.vvja || {};
/**
* Helper to get container by identifier.
*/
function getContainerByIdentifier(identifier) {
let container = document.querySelector(`[data-deeplink-id="${identifier}"]`);
if (!container) {
container = document.querySelector(identifier);
}
return container;
}
/**
* Open specific panel.
*/
Drupal.vvja.openPanel = function(identifier, panelIndex) {
const container = getContainerByIdentifier(identifier);
if (!container) {
console.warn(`VVJA: Accordion "${identifier}" not found`);
return false;
}
const buttons = container.querySelectorAll('.vvja-button');
if (panelIndex < 1 || panelIndex > buttons.length) {
console.warn(`VVJA: Invalid panel ${panelIndex}. Must be between 1 and ${buttons.length}`);
return false;
}
const button = buttons[panelIndex - 1];
const pane = button.nextElementSibling;
const isOpen = pane.getAttribute('aria-hidden') === 'false';
if (!isOpen) {
button.click();
}
return true;
};
/**
* Close specific panel.
*/
Drupal.vvja.closePanel = function(identifier, panelIndex) {
const container = getContainerByIdentifier(identifier);
if (!container) {
console.warn(`VVJA: Accordion "${identifier}" not found`);
return false;
}
const buttons = container.querySelectorAll('.vvja-button');
if (panelIndex < 1 || panelIndex > buttons.length) {
console.warn(`VVJA: Invalid panel ${panelIndex}. Must be between 1 and ${buttons.length}`);
return false;
}
const button = buttons[panelIndex - 1];
const pane = button.nextElementSibling;
const isOpen = pane.getAttribute('aria-hidden') === 'false';
if (isOpen) {
button.click();
}
return true;
};
/**
* Toggle specific panel.
*/
Drupal.vvja.togglePanel = function(identifier, panelIndex) {
const container = getContainerByIdentifier(identifier);
if (!container) {
console.warn(`VVJA: Accordion "${identifier}" not found`);
return false;
}
const buttons = container.querySelectorAll('.vvja-button');
if (panelIndex < 1 || panelIndex > buttons.length) {
console.warn(`VVJA: Invalid panel ${panelIndex}. Must be between 1 and ${buttons.length}`);
return false;
}
buttons[panelIndex - 1].click();
return true;
};
/**
* Get array of open panel indices.
*/
Drupal.vvja.getOpenPanels = function(identifier) {
const container = getContainerByIdentifier(identifier);
if (!container) return null;
const panes = container.querySelectorAll('.vvja-pane');
const openPanels = [];
panes.forEach((pane, index) => {
if (pane.getAttribute('aria-hidden') === 'false') {
openPanels.push(index + 1);
}
});
return openPanels;
};
/**
* Get total number of panels.
*/
Drupal.vvja.getTotalPanels = function(identifier) {
const container = getContainerByIdentifier(identifier);
if (!container) return null;
return container.querySelectorAll('.vvja-button').length;
};
/**
* Get accordion instance.
*/
Drupal.vvja.getInstance = function(containerOrSelector) {
const container = typeof containerOrSelector === 'string'
? document.querySelector(containerOrSelector)
: containerOrSelector;
return container ? container.closest('.vvja-inner') : null;
};
})(Drupal, drupalSettings, once);
