svarog-ui-core
Version:
Core components of Svarog UI without themes
327 lines (278 loc) • 8.88 kB
JavaScript
// src/components/CollapsibleHeader/CollapsibleHeaderContainer.js
import {
validateProps,
createComponent,
} from '../../utils/componentFactory.js';
import { debounce } from '../../utils/performance.js';
import { withThemeAwareness } from '../../utils/composition.js';
import CollapsibleHeader from './CollapsibleHeader.js';
import StickyContactIcons from '../StickyContactIcons/StickyContactIcons.js';
/**
* Migrates legacy props to standardized prop names
* @param {Object} props - Component properties
* @returns {Object} - Migrated properties
* @private
*/
const migrateLegacyProps = (props) => {
const migrated = { ...props };
// Migrate onCallButtonClick → onCallClick
if ('onCallButtonClick' in props && !('onCallClick' in props)) {
console.warn(
'[CollapsibleHeaderContainer] onCallButtonClick is deprecated, use onCallClick instead'
);
migrated.onCallClick = props.onCallButtonClick;
delete migrated.onCallButtonClick;
}
return migrated;
};
/**
* Creates a CollapsibleHeaderContainer component
* @param {Object} props - CollapsibleHeaderContainer properties
* @returns {Object} CollapsibleHeaderContainer component API
*/
const createCollapsibleHeaderContainer = (props) => {
// Standardize props
const standardizedProps = migrateLegacyProps(props);
// Validate props
validateProps(
standardizedProps,
createCollapsibleHeaderContainer.requiredProps
);
// Component state - use a simple object rather than getters/setters
const componentState = {
// Pass through all props to the header
...standardizedProps,
// Container specific state
isCollapsed: false,
isMobile: checkIsMobile(),
scrollY: 0,
// Container configuration
collapseThreshold: standardizedProps.collapseThreshold || 100,
showStickyIcons: standardizedProps.showStickyIcons !== false,
stickyIconsPosition: standardizedProps.stickyIconsPosition || 'right',
storyMode: standardizedProps._storyMode === true,
};
// Create header component
const header = CollapsibleHeader({
...standardizedProps,
isCollapsed: componentState.isCollapsed,
isMobile: componentState.isMobile,
});
// Create sticky icons if enabled
let stickyIcons = null;
if (componentState.showStickyIcons) {
stickyIcons = StickyContactIcons({
location: standardizedProps.contactInfo.location,
phone: standardizedProps.contactInfo.phone,
email: standardizedProps.contactInfo.email,
position: componentState.stickyIconsPosition,
className: 'collapsible-header__sticky-icons',
});
}
// Event handler references for cleanup
let windowScrollHandler = null;
let windowResizeHandler = null;
/**
* Check if viewport is mobile
* @returns {boolean} True if viewport is mobile
*/
function checkIsMobile() {
return typeof window !== 'undefined' && window.innerWidth <= 768;
}
/**
* Handle scroll events
*/
function handleScroll() {
if (typeof window === 'undefined') {
return;
}
const scrollY = window.scrollY;
// Check if scroll position passed threshold
const shouldCollapse = scrollY > componentState.collapseThreshold;
// Only update if state changed
if (shouldCollapse !== componentState.isCollapsed) {
componentState.isCollapsed = shouldCollapse;
// Update header
header.update({
isCollapsed: shouldCollapse,
});
// Update sticky icons
updateStickyIconsVisibility();
}
componentState.scrollY = scrollY;
}
/**
* Handle resize events
*/
function handleResize() {
const isMobile = checkIsMobile();
// Only update if state changed
if (isMobile !== componentState.isMobile) {
componentState.isMobile = isMobile;
// Update header
header.update({
isMobile,
});
// Update sticky icons
updateStickyIconsVisibility();
}
}
/**
* Update sticky icons visibility based on state
*/
function updateStickyIconsVisibility() {
if (!stickyIcons || !componentState.showStickyIcons) {
return;
}
const iconElement = stickyIcons.getElement();
const shouldShow = componentState.isCollapsed || componentState.isMobile;
// First time showing the icons, add to DOM
if (shouldShow && !iconElement.parentNode) {
document.body.appendChild(iconElement);
}
// Update visibility
iconElement.style.display = shouldShow ? 'flex' : 'none';
// Update position based on mobile state
if (componentState.isMobile) {
iconElement.style.position = 'fixed';
iconElement.style.bottom = '16px';
iconElement.style.right = '16px';
iconElement.style.top = 'auto';
iconElement.style.flexDirection = 'row';
} else {
iconElement.style.position = 'fixed';
const headerHeight = header.getElement().offsetHeight || 300;
iconElement.style.top = `${headerHeight + 200}px`;
iconElement.style.right = '0px';
iconElement.style.bottom = 'auto';
iconElement.style.flexDirection = 'column';
}
}
// Set up event listeners if not in story mode
if (!componentState.storyMode && typeof window !== 'undefined') {
// Use debounced handlers for better performance
windowScrollHandler = debounce(handleScroll, 10);
windowResizeHandler = debounce(handleResize, 100);
window.addEventListener('scroll', windowScrollHandler, { passive: true });
window.addEventListener('resize', windowResizeHandler, { passive: true });
}
// Special story mode helpers - completely isolated for testing only
const storyHelpers = {
setCollapsed(isCollapsed) {
componentState.isCollapsed = isCollapsed;
header.update({ isCollapsed });
},
getIconElement() {
return stickyIcons ? stickyIcons.getElement() : null;
},
getHeaderElement() {
return header.getElement();
},
isMobile() {
return componentState.isMobile;
},
isCollapsed() {
return componentState.isCollapsed;
},
};
const containerComponent = {
/**
* Get the header element
* @returns {HTMLElement} The header element
*/
getElement() {
return header.getElement();
},
/**
* Update component with new props
* @param {Object} newProps - New properties
* @returns {Object} Component (for chaining)
*/
update(newProps) {
// Standardize any new props
const standardizedNewProps = migrateLegacyProps(newProps);
// Update internal state
Object.assign(componentState, standardizedNewProps);
// Update header component
header.update(standardizedNewProps);
// Update sticky icons if needed
if (
componentState.showStickyIcons &&
(standardizedNewProps.isCollapsed !== undefined ||
standardizedNewProps.isMobile !== undefined)
) {
updateStickyIconsVisibility();
}
return this;
},
/**
* Clean up event listeners and resources
*/
destroy() {
// Remove event listeners
if (typeof window !== 'undefined') {
if (windowScrollHandler) {
window.removeEventListener('scroll', windowScrollHandler, {
passive: true,
});
}
if (windowResizeHandler) {
window.removeEventListener('resize', windowResizeHandler, {
passive: true,
});
}
}
// Remove sticky icons from DOM
if (stickyIcons) {
const iconElement = stickyIcons.getElement();
if (iconElement.parentNode) {
iconElement.parentNode.removeChild(iconElement);
}
}
// Destroy header component
if (header) {
header.destroy();
}
},
/**
* Force update of sticky icons visibility
* @public
*/
updateStickyIconsVisibility,
/**
* Get component state (for testing)
* @returns {Object} Current state
*/
getState() {
// Return a copy to prevent direct mutation
return { ...componentState };
},
/**
* Access to scroll handler for testing
* @public
*/
handleScroll,
/**
* Access to resize handler for testing
* @public
*/
handleResize,
/**
* Special accessor for stories - completely isolated API
* @public
*/
_story: componentState.storyMode ? storyHelpers : null,
};
return containerComponent;
};
// Define required props for validation
createCollapsibleHeaderContainer.requiredProps = ['navigation', 'contactInfo'];
// Create the component with theme awareness
const CollapsibleHeaderContainer = withThemeAwareness(
createComponent(
'CollapsibleHeaderContainer',
createCollapsibleHeaderContainer
)
);
// Export as a factory function
export default CollapsibleHeaderContainer;