svarog-ui-core
Version:
Core components of Svarog UI without themes
439 lines (385 loc) • 14 kB
JavaScript
// src/components/FAQSection/FAQSection.js
import { createBaseComponent } from '../../utils/baseComponent.js';
import { withThemeAwareness } from '../../utils/composition.js';
import { createComponent } from '../../utils/componentFactory.js';
import { validateRequiredProps } from '../../utils/validation.js';
// Component imports
import Section from '../Section/Section.js';
import Accordion from '../Accordion/Accordion.js';
// CSS injection imports
import { createStyleInjector } from '../../utils/styleInjection.js';
import { faqSectionStyles } from './FAQSection.styles.js';
// Create style injector for FAQSection component
const injectFAQSectionStyles = createStyleInjector('FAQSection');
/**
* Creates a FAQ Section component that combines Section and Accordion
* @param {Object} props - FAQSection properties
* @param {Array} props.faqs - Array of FAQ items with question/answer structure
* @param {string} [props.title] - Section title
* @param {string} [props.description] - Section description
* @param {boolean} [props.multiple=false] - Allow multiple FAQs open (default: false for FAQs)
* @param {string} [props.variant='bordered'] - Accordion variant (default: bordered for FAQs)
* @param {string} [props.iconType='plus-minus'] - Icon type (default: plus-minus for FAQs)
* @param {Array} [props.defaultExpanded=[]] - Initially expanded FAQ IDs
* @param {string} [props.sectionVariant] - Section variant ('minor' for alternate styling)
* @param {string} [props.backgroundColor] - Section background color
* @param {boolean} [props.noPaddingBottom=false] - Remove section bottom padding
* @param {string} [props.className=''] - Additional CSS classes
* @param {string} [props.id] - Section ID for anchor links
* @param {Function} [props.onChange] - Callback when FAQ expansion changes
* @returns {Object} FAQSection component API
*/
const createFAQSection = (props = {}) => {
// Validate required props
validateRequiredProps(
props,
{
faqs: {
required: true,
type: 'array',
minLength: 1,
validator: (faqs) => {
const isValid = faqs.every(
(faq) => faq.question && faq.answer && (faq.id || faq.question)
);
return isValid || 'Each FAQ must have question and answer properties';
},
},
},
'FAQSection'
);
// Normalize FAQ items to Accordion format
const normalizeItems = (faqs) =>
faqs.map((faq, index) => ({
id: faq.id || `faq-${index + 1}`,
title: faq.question,
content: faq.answer,
}));
// Create initial state with smart defaults for FAQs
const state = {
faqs: props.faqs || [],
title: props.title || null,
description: props.description || null,
multiple: props.multiple !== undefined ? props.multiple : false, // Single mode for FAQs
variant: props.variant || 'bordered', // Bordered looks great for FAQs
iconType: props.iconType || 'plus-minus', // Perfect for FAQ sections
defaultExpanded: props.defaultExpanded || [],
sectionVariant: props.sectionVariant || null,
backgroundColor: props.backgroundColor || null,
noPaddingBottom: props.noPaddingBottom || false,
className: props.className || '',
id: props.id || null,
onChange: props.onChange || null,
};
// Internal component references - CONCISE cleanup management
let accordionComponent = null;
let sectionComponent = null;
/**
* Renders the FAQ section based on current state
* @param {Object} currentState - Current component state
* @returns {HTMLElement} FAQ section element
*/
const renderFAQSection = (currentState) => {
// Inject styles on render
injectFAQSectionStyles(faqSectionStyles);
// Prevent creating accordion with empty items
if (!currentState.faqs || currentState.faqs.length === 0) {
throw new Error('FAQSection: Cannot render with empty FAQ array');
}
// Create accordion with normalized items
accordionComponent = Accordion({
items: normalizeItems(currentState.faqs),
multiple: currentState.multiple,
variant: currentState.variant,
iconType: currentState.iconType,
defaultExpanded: currentState.defaultExpanded,
className: 'faq-section__accordion',
ariaLabel: currentState.title
? `${currentState.title} FAQs`
: 'Frequently Asked Questions',
onChange: currentState.onChange,
});
// Create section wrapper
sectionComponent = Section({
id: currentState.id,
title: currentState.title,
description: currentState.description,
variant: currentState.sectionVariant,
backgroundColor: currentState.backgroundColor,
noPaddingBottom: currentState.noPaddingBottom,
className: `faq-section ${currentState.className}`.trim(),
children: [accordionComponent.getElement()],
});
return sectionComponent.getElement();
};
// Create base component
const baseComponent = createBaseComponent(renderFAQSection)(state);
// Enhanced component with FAQ-specific methods
const faqSectionComponent = {
...baseComponent,
/**
* Get current state (Svarog UI pattern)
* @returns {Object} Current component state
*/
getState() {
return { ...state }; // Return copy to prevent direct mutation
},
/**
* Get the internal accordion component
* @returns {Object} Accordion component instance
*/
getAccordion() {
return accordionComponent;
},
/**
* Expand a specific FAQ by ID or index
* @param {string|number} identifier - FAQ ID or zero-based index
* @returns {Object} FAQSection component (for chaining)
*/
expandFAQ(identifier) {
if (!accordionComponent) return this;
try {
const faqId =
typeof identifier === 'number' ? `faq-${identifier + 1}` : identifier;
accordionComponent.expand(faqId);
} catch (error) {
console.warn('FAQSection.expandFAQ error:', error.message);
}
return this;
},
/**
* Collapse a specific FAQ by ID or index
* @param {string|number} identifier - FAQ ID or zero-based index
* @returns {Object} FAQSection component (for chaining)
*/
collapseFAQ(identifier) {
if (!accordionComponent) return this;
try {
const faqId =
typeof identifier === 'number' ? `faq-${identifier + 1}` : identifier;
accordionComponent.collapse(faqId);
} catch (error) {
console.warn('FAQSection.collapseFAQ error:', error.message);
}
return this;
},
/**
* Toggle a specific FAQ by ID or index
* @param {string|number} identifier - FAQ ID or zero-based index
* @returns {Object} FAQSection component (for chaining)
*/
toggleFAQ(identifier) {
if (!accordionComponent) return this;
try {
const faqId =
typeof identifier === 'number' ? `faq-${identifier + 1}` : identifier;
accordionComponent.toggle(faqId);
} catch (error) {
console.warn('FAQSection.toggleFAQ error:', error.message);
}
return this;
},
/**
* Expand all FAQs (only works when multiple is true)
* @returns {Object} FAQSection component (for chaining)
*/
expandAll() {
if (accordionComponent) {
try {
accordionComponent.expandAll();
} catch (error) {
console.warn('FAQSection.expandAll error:', error.message);
}
}
return this;
},
/**
* Collapse all FAQs
* @returns {Object} FAQSection component (for chaining)
*/
collapseAll() {
if (accordionComponent) {
try {
accordionComponent.collapseAll();
} catch (error) {
console.warn('FAQSection.collapseAll error:', error.message);
}
}
return this;
},
/**
* Get currently expanded FAQ IDs
* @returns {Array} Array of expanded FAQ IDs
*/
getExpandedFAQs() {
try {
return accordionComponent ? accordionComponent.getExpandedItems() : [];
} catch (error) {
console.warn('FAQSection.getExpandedFAQs error:', error.message);
return [];
}
},
/**
* Set the FAQ icon type
* @param {string} iconType - Icon type for the accordion
* @returns {Object} FAQSection component (for chaining)
*/
setIconType(iconType) {
if (accordionComponent) {
try {
accordionComponent.setIconType(iconType);
} catch (error) {
console.warn('FAQSection.setIconType error:', error.message);
}
}
return this;
},
/**
* Add a new FAQ to the section
* @param {Object} faq - FAQ object with question and answer
* @param {number} [index] - Optional position to insert (default: append)
* @returns {Object} FAQSection component (for chaining)
*/
addFAQ(faq, index) {
if (!faq.question || !faq.answer) {
throw new Error('FAQ must have question and answer properties');
}
const currentState = this.getState();
const newFaqs = [...currentState.faqs];
if (typeof index === 'number' && index >= 0 && index <= newFaqs.length) {
newFaqs.splice(index, 0, faq);
} else {
newFaqs.push(faq);
}
this.update({ faqs: newFaqs });
return this;
},
/**
* Remove a FAQ by ID or index
* @param {string|number} identifier - FAQ ID or zero-based index
* @returns {Object} FAQSection component (for chaining)
*/
removeFAQ(identifier) {
const currentState = this.getState();
const newFaqs = currentState.faqs.filter((faq, index) => {
if (typeof identifier === 'number') {
return index !== identifier;
}
return faq.id !== identifier && faq.question !== identifier;
});
this.update({ faqs: newFaqs });
return this;
},
/**
* Update a specific FAQ
* @param {string|number} identifier - FAQ ID or zero-based index
* @param {Object} updates - FAQ updates
* @returns {Object} FAQSection component (for chaining)
*/
updateFAQ(identifier, updates) {
const currentState = this.getState();
const newFaqs = currentState.faqs.map((faq, index) => {
const matches =
typeof identifier === 'number'
? index === identifier
: faq.id === identifier || faq.question === identifier;
return matches ? { ...faq, ...updates } : faq;
});
this.update({ faqs: newFaqs });
return this;
},
/**
* Search FAQs by question or answer content
* @param {string} query - Search query
* @returns {Array} Array of matching FAQ objects with their indices
*/
searchFAQs(query) {
if (!query || typeof query !== 'string') return [];
const currentState = this.getState();
const searchTerm = query.toLowerCase();
return currentState.faqs
.map((faq, index) => ({ ...faq, index }))
.filter((faq) => {
const questionMatch =
faq.question && faq.question.toLowerCase().includes(searchTerm);
const answerMatch =
faq.answer &&
typeof faq.answer === 'string' &&
faq.answer.toLowerCase().includes(searchTerm);
return questionMatch || answerMatch;
});
},
/**
* CONCISE CLEANUP - Override destroy for proper component cleanup
* Following Svarog principles: Economy of Expression + Algorithmic Elegance
*/
destroy() {
// Get element reference before cleanup
const element = this.getElement();
// Clean up internal components in dependency order - CONCISE
accordionComponent?.destroy();
sectionComponent?.destroy();
// Remove element from DOM if it has a parent - CRITICAL for test
element?.parentNode?.removeChild(element);
// Nullify references - prevent memory leaks
accordionComponent = null;
sectionComponent = null;
// Call base destroy for final cleanup
baseComponent.destroy();
},
};
// Override update to handle FAQ-specific updates
const originalUpdate = faqSectionComponent.update;
faqSectionComponent.update = function (newProps) {
// Update internal state
Object.assign(state, newProps);
// If FAQs changed, we need to rebuild the accordion
if (newProps.faqs) {
originalUpdate.call(this, newProps);
return this;
}
// For other props, try to update accordion and section directly
if (
accordionComponent &&
(newProps.multiple !== undefined ||
newProps.variant !== undefined ||
newProps.iconType !== undefined ||
newProps.defaultExpanded !== undefined ||
newProps.onChange !== undefined)
) {
try {
accordionComponent.update({
multiple: newProps.multiple,
variant: newProps.variant,
iconType: newProps.iconType,
defaultExpanded: newProps.defaultExpanded,
onChange: newProps.onChange,
});
} catch (error) {
console.warn('Error updating accordion:', error.message);
}
}
// Update section props if they exist
const sectionElement = this.getElement();
if (
sectionElement &&
(newProps.id !== undefined ||
newProps.backgroundColor !== undefined ||
newProps.className !== undefined ||
newProps.sectionVariant !== undefined ||
newProps.noPaddingBottom !== undefined)
) {
// For major section changes, do a full update
originalUpdate.call(this, newProps);
return this;
}
return this;
};
return faqSectionComponent;
};
// Create the component with theme awareness
const FAQSectionComponent = withThemeAwareness(
createComponent('FAQSection', createFAQSection)
);
// Export as factory function
export default FAQSectionComponent;