SectionTypePanel (Accordion/Tabs/Testimonials/Countdown/NumberCounter/ CTASection/CallToAction/FeaturesGrid), PricingStylePanel, and SocialStylePanel all gain a Spacing & Border section (margin/padding per-side, border, box-shadow, opacity), an Animation section, and a Visibility section, wired to the shared SpacingControl/BorderControl/ AnimationControl/VisibilityControl. PricingTable's per-card colors (cardBg/textColor/subColor/featColor/ checkColor/btnBg/btnColor) were previously hard-coded literals computed from featuredBg inside toHtml -- promoted to real optional props (each falling back to the exact prior literal when unset) and exposed via ColorPickerField in PricingStylePanel. SocialStylePanel now exposes SocialLinks' iconShape/gap (already-built props with no control), plus Icon's bgColor/bgShape/bgSize/link and StarRating's filledColor/emptyColor, which the panel's generic iconBgColor/starColor checks never matched since those aren't Icon's or StarRating's real prop names. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
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<StarRatingProps> = ({
|
|
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(
|
|
<i
|
|
key={i}
|
|
className="fa fa-star"
|
|
style={{ color: filledColor, fontSize: size }}
|
|
/>
|
|
);
|
|
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
|
|
// Half star
|
|
stars.push(
|
|
<span key={i} style={{ position: 'relative', display: 'inline-block', fontSize: size }}>
|
|
<i className="fa fa-star" style={{ color: emptyColor }} />
|
|
<span style={{ position: 'absolute', left: 0, top: 0, overflow: 'hidden', width: '50%' }}>
|
|
<i className="fa fa-star" style={{ color: filledColor }} />
|
|
</span>
|
|
</span>
|
|
);
|
|
} else {
|
|
// Empty star
|
|
stars.push(
|
|
<i
|
|
key={i}
|
|
className="fa fa-star"
|
|
style={{ color: emptyColor, fontSize: size }}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<span
|
|
ref={(ref: HTMLSpanElement | null): void => { if (ref) connect(drag(ref)); }}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '2px',
|
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
|
...style,
|
|
}}
|
|
>
|
|
{stars}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
/* ---------- 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 += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
|
|
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
|
|
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
|
|
} else {
|
|
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
|
|
}
|
|
}
|
|
|
|
// 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: `<span role="img" aria-label="${ariaLabel}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
|
|
};
|
|
};
|