svarog-ui-core
Version:
Core components of Svarog UI without themes
363 lines (315 loc) • 11.2 kB
JavaScript
// src/components/Rating/Rating.js
import { createBaseComponent } from '../../utils/baseComponent.js';
import { createElement } from '../../utils/componentFactory.js';
import { validateRequiredProps } from '../../utils/validation.js';
import { createStyleInjector } from '../../utils/styleInjection.js';
import { ratingStyles } from './Rating.styles.js';
// Create style injector for Rating component
const injectRatingStyles = createStyleInjector('Rating');
// SVG logos for different sources - defined outside the component for better performance
const LOGO_SVGS = {
google: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Google logo">
<path fill="#4285F4" d="M45.12 24.5c0-1.56-.14-3.06-.4-4.5H24v8.51h11.84c-.51 2.75-2.06 5.08-4.42 6.63v5.52h7.16c4.18-3.85 6.64-9.53 6.64-16.26z"/>
<path fill="#34A853" d="M24 46c5.96 0 10.96-1.97 14.62-5.33l-7.16-5.52c-1.97 1.32-4.49 2.1-7.46 2.1-5.74 0-10.61-3.88-12.36-9.1H4.34v5.7C8.02 41.54 15.4 46 24 46z"/>
<path fill="#FBBC05" d="M11.64 28.15c-.88-2.62-.88-5.47 0-8.1V14.35H4.34c-2.83 5.67-2.83 12.33 0 18l7.3-5.7z"/>
<path fill="#EA4335" d="M24 9.5c3.25 0 6.18 1.18 8.48 3.48l6.36-6.36C34.96 2.54 29.96 0 24 0 15.4 0 8.02 4.46 4.34 11.35l7.3 5.7C13.39 13.38 18.26 9.5 24 9.5z"/>
</svg>
`,
facebook: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Facebook logo">
<path fill="#3F51B5" d="M24 4C12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20S35.05 4 24 4z"/>
<path fill="#FFF" d="M26.57 35v-9.63h3.23l.48-3.75h-3.71v-2.4c0-1.09.3-1.83 1.86-1.83h2v-3.36c-.35-.05-1.54-.15-2.93-.15-2.9 0-4.89 1.77-4.89 5.02v2.79h-3.27v3.75h3.27V35h3.96z"/>
</svg>
`,
trustpilot: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Trustpilot logo">
<path fill="#00B67A" d="M24 4l5.49 16.88H47l-14.36 10.43 5.49 16.88L24 37.75l-14.13 10.44 5.49-16.88L1 20.88h17.51z"/>
</svg>
`,
};
// Valid sources for the rating component
const VALID_SOURCES = ['google', 'facebook', 'trustpilot'];
/**
* Format large numbers with abbreviated notation
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
const formatNumber = (num) => {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
};
/**
* Migrates legacy props to standardized props
* @param {Object} props - Component props
* @returns {Object} Normalized props
*/
const migrateLegacyProps = (props) => {
const migrated = { ...props };
if (
props.options?.fallbackImageSrc !== undefined &&
!props.options?.fallbackImageUrl
) {
console.warn(
'[Rating] options.fallbackImageSrc is deprecated, use options.fallbackImageUrl instead'
);
migrated.options = {
...migrated.options,
fallbackImageUrl: props.options.fallbackImageSrc,
};
// Keep fallbackImageSrc for backward compatibility
}
return migrated;
};
/**
* Creates a Rating component for displaying ratings from various sources
* @param {Object} props - Rating properties
* @returns {Object} Rating component
*/
const createRating = (props) => {
// Migrate legacy props
const normalizedProps = migrateLegacyProps(props);
// Validate required props
validateRequiredProps(
normalizedProps,
{
source: { type: 'string', required: true },
score: { required: true },
totalRatings: { required: true },
},
'Rating'
);
// Validate source
const normalizedSource = normalizedProps.source.toLowerCase();
if (!VALID_SOURCES.includes(normalizedSource)) {
throw new Error(
`Rating: source must be one of: ${VALID_SOURCES.join(', ')}`
);
}
// Validate score range
const score = parseFloat(normalizedProps.score);
if (isNaN(score) || score < 0 || score > 5) {
throw new Error('Rating: score must be a number between 0 and 5');
}
/**
* Create source logo element
* @param {string} source - Rating source
* @returns {HTMLElement} Logo element with SVG
*/
const createSourceLogo = (source) => {
const logoContainer = createElement('div', {
classes: [`rating__logo`, `rating__logo--${source}`],
});
// Create SVG element
const svgElement = createElement('div', {
classes: ['rating__logo-svg'],
html: LOGO_SVGS[source] || LOGO_SVGS.google,
});
logoContainer.appendChild(svgElement);
return logoContainer;
};
/**
* Create star rating element
* @param {number} score - Rating score
* @returns {HTMLElement} Star rating element
*/
const createStarRating = (score) => {
const starContainer = createElement('div', {
classes: ['rating__stars'],
attributes: {
role: 'img',
'aria-label': `Rating: ${score} out of 5 stars`,
},
});
// Calculate full, half, and empty stars
const fullStars = Math.floor(score);
const halfStar = score % 1 >= 0.5 ? 1 : 0;
const emptyStars = 5 - fullStars - halfStar;
// Create star elements
const createStars = (type, count) => {
for (let i = 0; i < count; i++) {
const star = createElement('span', {
classes: [`rating__star`, `rating__star--${type}`],
text: '★',
attributes: {
'aria-hidden': 'true',
},
});
starContainer.appendChild(star);
}
};
createStars('full', fullStars);
createStars('half', halfStar);
createStars('empty', emptyStars);
return starContainer;
};
/**
* Create reviewer images element
* @param {Array} images - Image URLs
* @param {Object} options - Display options
* @returns {HTMLElement|null} Reviewer images element
*/
const createReviewerImages = (images, options) => {
// Skip if images are disabled or empty
if (!options.showReviewerImages || images.length === 0) {
return null;
}
const imagesContainer = createElement('div', {
classes: ['rating__reviewer-images'],
attributes: {
'aria-label': 'Reviewer profile images',
},
});
// Limit number of images
const imagesToShow = images.slice(0, options.maxReviewerImages);
imagesToShow.forEach((imageUrl, index) => {
const img = createElement('img', {
attributes: {
src: imageUrl,
alt: `Reviewer profile ${index + 1}`,
},
classes: ['rating__reviewer-image'],
});
imagesContainer.appendChild(img);
});
return imagesContainer;
};
/**
* Renders the rating element based on current state
* @param {Object} state - Current component state
* @returns {HTMLElement} Rating element
*/
const renderRating = (state) => {
// Inject styles on first render
injectRatingStyles(ratingStyles);
// Normalize and prepare data
const source = state.source.toLowerCase();
const score = parseFloat(state.score).toFixed(1);
const totalRatings = formatNumber(state.totalRatings);
const reviewerImages = state.reviewerImages || [];
const options = {
showReviewerImages: state.options?.showReviewerImages !== false,
maxReviewerImages: state.options?.maxReviewerImages || 5,
fallbackImageUrl:
state.options?.fallbackImageUrl ||
state.options?.fallbackImageSrc ||
'',
};
// Create the main container
const container = createElement('div', {
classes: [`rating`, `rating--${source}`],
attributes: {
role: 'region',
'aria-label': `${source} rating`,
},
});
// Create score section
const scoreSection = createElement('div', {
classes: ['rating__score-section'],
});
// Add logo
const logo = createSourceLogo(source);
scoreSection.appendChild(logo);
// Create score details
const scoreDetails = createElement('div', {
classes: ['rating__score-details'],
});
// Score number
const scoreNumber = createElement('div', {
classes: ['rating__score-number'],
text: score,
});
scoreDetails.appendChild(scoreNumber);
// Star rating
const starRating = createStarRating(parseFloat(score));
scoreDetails.appendChild(starRating);
// Total ratings
const totalRatingsElement = createElement('div', {
classes: ['rating__total-ratings'],
text: `${totalRatings} Bewertungen`,
});
scoreDetails.appendChild(totalRatingsElement);
scoreSection.appendChild(scoreDetails);
container.appendChild(scoreSection);
// Add reviewer images if available
const reviewerImagesElement = createReviewerImages(reviewerImages, options);
if (reviewerImagesElement) {
container.appendChild(reviewerImagesElement);
}
return container;
};
// Create component using baseComponent
const baseComponent = createBaseComponent(renderRating)(normalizedProps);
// Extended component with custom methods
const ratingComponent = {
...baseComponent,
update(props) {
// Ensure legacy props are migrated
const normalizedUpdateProps = migrateLegacyProps(props);
// Call base update with normalized props
return baseComponent.update(normalizedUpdateProps);
},
/**
* Determines if component should fully re-render
* @param {Object} newProps - New properties
* @returns {boolean} Whether a full re-render is required
*/
shouldRerender(newProps) {
// Migrate legacy props before checking
const normalizedNewProps = migrateLegacyProps(newProps);
// Re-render if these critical props change
const criticalProps = [
'source',
'score',
'totalRatings',
'reviewerImages',
'options',
];
return Object.keys(normalizedNewProps).some((key) =>
criticalProps.includes(key)
);
},
/**
* Updates the rating score
* @param {number} newScore - New score value
* @returns {Object} Component for chaining
* @throws {Error} If score is invalid
*/
setScore(newScore) {
const score = parseFloat(newScore);
if (isNaN(score) || score < 0 || score > 5) {
throw new Error('Rating: score must be a number between 0 and 5');
}
return this.update({ score });
},
/**
* Updates the total ratings count
* @param {number} newTotal - New total ratings
* @returns {Object} Component for chaining
*/
setTotalRatings(newTotal) {
return this.update({ totalRatings: newTotal });
},
/**
* Theme change handler
* @param {string} newTheme - New theme name
* @param {string} previousTheme - Previous theme name
*/
onThemeChange(newTheme, previousTheme) {
console.debug(
`Rating: theme changed from ${previousTheme} to ${newTheme}`
);
// Theme-specific adjustments could be added here
},
};
return ratingComponent;
};
// Define required props for validation
createRating.requiredProps = ['source', 'score', 'totalRatings'];
// Export as a factory function
export default createRating;