svarog-ui-core
Version:
Core components of Svarog UI without themes
669 lines (583 loc) • 17.4 kB
JavaScript
// src/components/Page/Page.js
import { createElement, validateProps } from '../../utils/componentFactory.js';
import { createBaseComponent } from '../../utils/baseComponent.js';
import Head from '../Head/Head.js';
// CSS injection imports
import { createStyleInjector } from '../../utils/styleInjection.js';
import { pageStyles } from './Page.styles.js';
// Create style injector for Page component
const injectPageStyles = createStyleInjector('Page');
/**
* Migrates legacy props to standardized props
* @param {Object} props - Original props
* @returns {Object} Standardized props
*/
const migrateLegacyProps = (props) => {
const migrated = { ...props };
// No legacy props to migrate yet, but following the pattern
// for future compatibility
return migrated;
};
/**
* Validates page-specific props
* @param {Object} props - Page properties
*/
const validatePageProps = (props) => {
if (!props.content && !props.children) {
console.warn('Page: No content or children provided');
}
if (props.seo?.title && props.seo.title.length > 60) {
console.warn('Page: SEO title exceeds recommended 60 characters');
}
if (props.seo?.description && props.seo.description.length > 160) {
console.warn('Page: SEO description exceeds recommended 160 characters');
}
};
/**
* Creates the page loading state
* @param {Object} options - Loading options
* @returns {HTMLElement} Loading element
*/
const createLoadingState = (options = {}) => {
const { message = 'Loading...', showSpinner = true } = options;
const container = createElement('div', {
classes: ['page__loading'],
attributes: {
'aria-live': 'polite',
'aria-label': message,
},
});
if (showSpinner) {
const spinner = createElement('div', {
classes: ['page__loading-spinner'],
attributes: { 'aria-hidden': 'true' },
});
container.appendChild(spinner);
}
const text = createElement('p', {
classes: ['page__loading-text'],
});
text.textContent = message;
container.appendChild(text);
return container;
};
/**
* Creates the page error state
* @param {Object} error - Error information
* @param {Function} onRetry - Retry callback
* @returns {HTMLElement} Error element
*/
const createErrorState = (error, onRetry) => {
const {
message = 'Something went wrong',
code = 500,
title = 'Error',
} = error;
const container = createElement('div', {
classes: ['page__error'],
attributes: {
role: 'alert',
'aria-live': 'assertive',
},
});
const titleEl = createElement('h1', {
classes: ['page__error-title'],
});
titleEl.textContent = title;
container.appendChild(titleEl);
const messageEl = createElement('p', {
classes: ['page__error-message'],
});
messageEl.textContent = message;
container.appendChild(messageEl);
if (code) {
const codeEl = createElement('p', {
classes: ['page__error-code'],
});
codeEl.textContent = `Error ${code}`;
container.appendChild(codeEl);
}
if (onRetry) {
const retryBtn = createElement('button', {
classes: ['page__error-retry'],
});
retryBtn.textContent = 'Try Again';
retryBtn.addEventListener('click', onRetry);
container.appendChild(retryBtn);
}
return container;
};
/**
* Creates the page header section
* @param {Object} headerConfig - Header configuration
* @param {Function} componentMapper - Function to map components
* @returns {HTMLElement|null} Header element
*/
const createPageHeader = (headerConfig, componentMapper) => {
if (!headerConfig) return null;
const header = createElement('header', {
classes: ['page__header'],
attributes: {
role: 'banner',
},
});
if (componentMapper && headerConfig.components) {
headerConfig.components.forEach((component) => {
const renderedComponent = componentMapper(component);
if (renderedComponent) {
header.appendChild(renderedComponent);
}
});
}
return header;
};
/**
* Creates the page main content section
* @param {Object} contentConfig - Content configuration
* @param {Function} componentMapper - Function to map components
* @returns {HTMLElement} Main content element
*/
const createPageMain = (contentConfig, componentMapper) => {
const main = createElement('main', {
classes: ['page__main'],
attributes: {
role: 'main',
id: 'main-content',
},
});
if (componentMapper && contentConfig?.components) {
contentConfig.components.forEach((component) => {
const renderedComponent = componentMapper(component);
if (renderedComponent) {
main.appendChild(renderedComponent);
}
});
} else if (contentConfig?.html) {
main.innerHTML = contentConfig.html;
} else if (contentConfig?.text) {
main.textContent = contentConfig.text;
}
return main;
};
/**
* Creates the page footer section
* @param {Object} footerConfig - Footer configuration
* @param {Function} componentMapper - Function to map components
* @returns {HTMLElement|null} Footer element
*/
const createPageFooter = (footerConfig, componentMapper) => {
if (!footerConfig) return null;
const footer = createElement('footer', {
classes: ['page__footer'],
attributes: {
role: 'contentinfo',
},
});
if (componentMapper && footerConfig.components) {
footerConfig.components.forEach((component) => {
const renderedComponent = componentMapper(component);
if (renderedComponent) {
footer.appendChild(renderedComponent);
}
});
}
return footer;
};
/**
* Renders the complete page structure
* @param {Object} state - Component state
* @returns {HTMLElement} Page container element
*/
const renderPage = (state) => {
// Inject styles on render (automatically cached)
injectPageStyles(pageStyles);
const container = createElement('div', {
classes: ['page'],
attributes: {
'data-page-id': state.id || 'page',
'data-page-type': state.type || 'default',
},
});
// Handle different page states
if (state.loading) {
container.appendChild(createLoadingState(state.loadingOptions));
return container;
}
if (state.error) {
container.appendChild(createErrorState(state.error, state.onRetry));
return container;
}
// Optional skip to main content link for accessibility
if (state.includeSkipLink !== false) {
const skipLink = createElement('a', {
classes: ['page__skip-link'],
attributes: {
href: '#main-content',
},
});
skipLink.textContent = 'Skip to main content';
container.appendChild(skipLink);
}
// Create page sections
const header = createPageHeader(state.header, state.componentMapper);
const main = createPageMain(state.content, state.componentMapper);
const footer = createPageFooter(state.footer, state.componentMapper);
// Append sections to container
[header, main, footer].forEach((section) => {
if (section) container.appendChild(section);
});
return container;
};
/**
* Creates a Page component for managing complete page structure and SEO
* @param {Object} props - Page properties
* @returns {Object} Page component with standard API
*/
const createPage = (props) => {
// Migrate legacy props to standardized props
const standardizedProps = migrateLegacyProps(props);
// Validate required props
validateProps(standardizedProps, createPage.requiredProps, 'Page');
validatePageProps(standardizedProps);
// Initialize page head management
let headComponent = null;
if (standardizedProps.seo) {
headComponent = Head(standardizedProps.seo);
}
// Process initial state
const initialState = {
id: standardizedProps.id,
type: standardizedProps.type || 'page',
loading: standardizedProps.loading || false,
error: standardizedProps.error || null,
loadingOptions: standardizedProps.loadingOptions || {},
onRetry: standardizedProps.onRetry,
header: standardizedProps.header,
content: standardizedProps.content || standardizedProps.children,
footer: standardizedProps.footer,
componentMapper: standardizedProps.componentMapper,
seo: standardizedProps.seo,
includeSkipLink: standardizedProps.includeSkipLink,
};
// Create base component
const pageComponent = createBaseComponent(renderPage)(initialState);
// Create state management with direct object approach
const componentState = {
current: { ...initialState },
get() {
return { ...this.current };
},
set(newState) {
this.current = { ...this.current, ...newState };
},
};
// Add state methods directly to component object
Object.defineProperty(pageComponent, 'getState', {
value() {
return componentState.get();
},
writable: false,
enumerable: false,
configurable: false,
});
Object.defineProperty(pageComponent, 'setState', {
value(newState) {
componentState.set(newState);
},
writable: false,
enumerable: false,
configurable: false,
});
// Override update method to sync with state
const originalUpdate = pageComponent.update;
Object.defineProperty(pageComponent, 'update', {
value(newProps) {
// Standardize incoming props on update as well
const standardized = migrateLegacyProps(newProps);
this.setState(standardized);
return originalUpdate.call(this, standardized);
},
writable: false,
enumerable: false,
configurable: false,
});
// Enhanced component methods
/**
* Sets the page loading state
* @param {boolean} loading - Loading state
* @param {Object} options - Loading options
* @returns {Object} Component instance for chaining
*/
Object.defineProperty(pageComponent, 'setLoading', {
value(loading, options = {}) {
const currentState = this.getState();
const currentOptions = currentState.loadingOptions || {};
const newState = {
loading,
loadingOptions: { ...currentOptions, ...options },
};
this.setState(newState);
this.update(newState);
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Sets the page error state
* @param {Object|null} error - Error information
* @returns {Object} Component instance for chaining
*/
Object.defineProperty(pageComponent, 'setError', {
value(error) {
const newState = { error, loading: false };
this.setState(newState);
this.update(newState);
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Updates page content
* @param {Object} contentData - New content data
* @returns {Object} Component instance for chaining
*/
Object.defineProperty(pageComponent, 'setContent', {
value(contentData) {
const newState = {
content: contentData,
error: null, // Clear error state when updating content
loading: false, // Ensure loading is also cleared
};
this.setState(newState);
this.update(newState);
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Updates page SEO metadata
* @param {Object} seoData - New SEO data
* @returns {Object} Component instance for chaining
*/
Object.defineProperty(pageComponent, 'updateSEO', {
value(seoData) {
if (headComponent) {
headComponent.update(seoData);
headComponent.render();
} else {
headComponent = Head(seoData);
headComponent.render();
}
const currentState = this.getState();
const currentSEO = currentState.seo || {};
const newSEO = { ...currentSEO, ...seoData };
const newState = { seo: newSEO };
this.setState(newState);
this.update(newState);
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Renders page SEO metadata to document head
* @returns {Object} Component instance for chaining
*/
Object.defineProperty(pageComponent, 'renderSEO', {
value() {
if (headComponent) {
headComponent.render();
}
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Sets up page for specific CMS content
* @param {Object} cmsData - CMS-specific data
* @returns {Object} Component instance for chaining
*/
Object.defineProperty(pageComponent, 'loadFromCMS', {
value(cmsData) {
const { seo, header, content, footer } = cmsData;
// Update SEO if provided
if (seo) {
this.updateSEO(seo);
}
// Update page structure
this.update({
header,
content,
footer,
loading: false,
error: null,
});
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Handles page navigation
* @param {string} path - New page path
* @param {Object} options - Navigation options
* @returns {Promise} Navigation promise
*/
Object.defineProperty(pageComponent, 'navigate', {
async value(path, options = {}) {
const { showLoading = true, onNavigate } = options;
if (showLoading) {
const currentState = this.getState();
const currentOptions = currentState.loadingOptions || {};
this.setLoading(true, {
...currentOptions,
message: 'Loading page...',
});
}
try {
if (onNavigate) {
const result = await onNavigate(path);
if (result) {
this.loadFromCMS(result);
}
}
} catch (_error) {
this.setError({
message: 'Failed to load page',
code: 500,
title: 'Navigation Error',
});
}
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Optimizes page performance
* @returns {Object} Component instance for chaining
*/
Object.defineProperty(pageComponent, 'optimize', {
value() {
const element = this.getElement();
// Lazy load images
const images = element.querySelectorAll('img[data-src]');
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
});
});
images.forEach((img) => imageObserver.observe(img));
}
// Preload critical resources
this.preloadCriticalResources();
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Preloads critical resources
* @returns {Object} Component instance for chaining
*/
Object.defineProperty(pageComponent, 'preloadCriticalResources', {
value() {
const state = this.getState();
// Preload critical CSS/JS if specified
if (state.seo?.preload) {
state.seo.preload.forEach((resource) => {
const link = createElement('link', {
attributes: {
rel: 'preload',
href: resource.href,
as: resource.as || 'script',
},
});
document.head.appendChild(link);
});
}
return this;
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Validates page accessibility
* @returns {Object} Accessibility report
*/
Object.defineProperty(pageComponent, 'validateAccessibility', {
value() {
const element = this.getElement();
const issues = [];
// Check for skip link (only if enabled)
const state = this.getState();
if (
state.includeSkipLink !== false &&
!element.querySelector('.page__skip-link')
) {
issues.push('Missing skip to main content link');
}
// Check for main landmark
if (!element.querySelector('main[role="main"]')) {
issues.push('Missing main landmark');
}
// Check for heading hierarchy
const headings = element.querySelectorAll('h1, h2, h3, h4, h5, h6');
if (headings.length === 0) {
issues.push('No heading elements found');
}
return {
valid: issues.length === 0,
issues,
};
},
writable: false,
enumerable: false,
configurable: false,
});
/**
* Enhanced destroy method that cleans up head component
*/
const originalDestroy = pageComponent.destroy;
Object.defineProperty(pageComponent, 'destroy', {
value() {
if (headComponent) {
headComponent.destroy();
headComponent = null;
}
return originalDestroy.call(this);
},
writable: false,
enumerable: false,
configurable: false,
});
// Render SEO on initialization if provided
if (headComponent) {
headComponent.render();
}
return pageComponent;
};
// Define required props for validation
createPage.requiredProps = [];
// Export as both default and named export
export default createPage;
export { createPage as Page };