import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { cssValue, escapeAttr } from '../../utils/escape'; interface StarRatingProps { rating?: number; maxStars?: number; size?: string; filledColor?: string; emptyColor?: string; style?: CSSProperties; animation?: string; animationDelay?: string; hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; } export const StarRating: UserComponent = ({ rating = 4.5, maxStars = 5, size = '24px', filledColor = '#f59e0b', emptyColor = '#d1d5db', style = {}, }) => { const { connectors: { connect, drag }, selected, } = useNode((node) => ({ selected: node.events.selected, })); const stars: React.ReactNode[] = []; for (let i = 1; i <= maxStars; i++) { if (i <= Math.floor(rating)) { // Full star stars.push( ); } else if (i === Math.ceil(rating) && rating % 1 !== 0) { // Half star stars.push( ); } else { // Empty star stars.push( ); } } return ( { if (ref) connect(drag(ref)); }} style={{ display: 'inline-flex', alignItems: 'center', gap: '2px', outline: selected ? '2px solid #3b82f6' : 'none', ...style, }} > {stars} ); }; /* ---------- Craft config ---------- */ StarRating.craft = { displayName: 'Star Rating', props: { rating: 4.5, maxStars: 5, size: '24px', filledColor: '#f59e0b', emptyColor: '#d1d5db', style: {}, animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false, }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true, }, }; /* ---------- HTML export ---------- */ (StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => { // `rating`/`maxStars` are declared `number` in TS but arrive unchecked at // runtime (AI update_props only validates node_id; deserialized saved // state is untyped JSON) -- a string like `5" onmouseover="alert(1)` // breaks out of the aria-label attribute below, and an uncoerced/unclamped // maxStars can also blow up the star-glyph loop (NaN, absurd loop count, // or -- observed -- a RangeError from string concatenation overflow with // e.g. maxStars=1e9). Coerce to numbers with sane fallbacks/clamps first. const ratingRaw = Number(props.rating); const rating = Number.isFinite(ratingRaw) ? ratingRaw : 4.5; const maxStarsRaw = Number(props.maxStars); const maxStars = Number.isFinite(maxStarsRaw) ? Math.min(Math.max(Math.trunc(maxStarsRaw), 0), 50) : 5; // Sanitized -- raw string-interpolation sinks in the star glyphs below. const size = cssValue(props.size) || '24px'; const filledColor = cssValue(props.filledColor) || '#f59e0b'; const emptyColor = cssValue(props.emptyColor) || '#d1d5db'; const wrapperStyle = cssPropsToString({ display: 'inline-flex', alignItems: 'center', gap: '2px', ...props.style, }); let starsHtml = ''; for (let i = 1; i <= maxStars; i++) { if (i <= Math.floor(rating)) { starsHtml += ``; } else if (i === Math.ceil(rating) && rating % 1 !== 0) { starsHtml += ``; } else { starsHtml += ``; } } // The star glyphs convey nothing to assistive tech on their own -- wrap // in role="img" with a textual equivalent, and hide the decorative glyphs // themselves (aria-hidden above) so AT doesn't announce each icon. // Belt-and-suspenders: rating/maxStars are already coerced to numbers // above, but the assembled label is still run through escapeAttr() in // case a decimal/negative/Infinity edge case produces odd (though no // longer dangerous) text. const ariaLabel = escapeAttr(`Rating: ${rating} out of ${maxStars}`); return { html: `${starsHtml}`, }; };