Files
site-builder/craft/src/components/basic/StarRating.tsx
T

132 lines
3.9 KiB
TypeScript
Raw Normal View History

import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { cssValue } from '../../utils/escape';
interface StarRatingProps {
rating?: number;
maxStars?: number;
size?: string;
filledColor?: string;
emptyColor?: string;
style?: CSSProperties;
}
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: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
const rating = props.rating ?? 4.5;
const maxStars = props.maxStars || 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.
return {
html: `<span role="img" aria-label="Rating: ${rating} out of ${maxStars}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
};
};