@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
209 lines • 8.96 kB
JavaScript
import { Shade, createComponent } from '@furystack/shades';
import { buildTransition, cssVariableTheme } from '../services/css-variable-theme.js';
import { ThemeProviderService } from '../services/theme-provider-service.js';
import { Icon } from './icons/icon.js';
import { star as starIcon, starOutline } from './icons/icon-definitions.js';
/**
* Rating component for collecting user feedback with star ratings.
* Supports full and half-star precision, custom icons, hover feedback,
* keyboard navigation, and theme integration.
*/
export const Rating = Shade({
customElementName: 'shade-rating',
css: {
display: 'inline-flex',
fontFamily: cssVariableTheme.typography.fontFamily,
alignItems: 'center',
'& .rating-container': {
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
},
'& .rating-star': {
position: 'relative',
display: 'inline-flex',
cursor: 'pointer',
fontSize: cssVariableTheme.typography.fontSize.xl,
lineHeight: '1',
userSelect: 'none',
webkitUserSelect: 'none',
transition: buildTransition([
'transform',
cssVariableTheme.transitions.duration.fast,
cssVariableTheme.transitions.easing.default,
]),
},
'& .star-empty': {
color: cssVariableTheme.text.disabled,
},
'& .star-filled': {
position: 'absolute',
top: '0',
left: '0',
color: 'var(--rating-color)',
overflow: 'hidden',
pointerEvents: 'none',
whiteSpace: 'nowrap',
transition: buildTransition([
'width',
cssVariableTheme.transitions.duration.fast,
cssVariableTheme.transitions.easing.default,
]),
},
'&:not([data-disabled]):not([data-readonly]) .rating-star:hover': {
transform: 'scale(1.15)',
},
'&:focus-visible': {
outline: 'none',
boxShadow: `0 0 0 2px ${cssVariableTheme.palette.primary.main} inset`,
},
'&[data-size="small"] .rating-star': {
fontSize: '18px',
},
'&[data-size="large"] .rating-star': {
fontSize: '32px',
},
'&[data-disabled] .rating-star': {
cursor: 'not-allowed',
opacity: cssVariableTheme.action.disabledOpacity,
},
'&[data-disabled] .rating-star:hover': {
transform: 'none',
},
'&[data-readonly] .rating-star': {
cursor: 'default',
},
'&[data-readonly] .rating-star:hover': {
transform: 'none',
},
},
render: ({ props, injector, useHostProps, useRef }) => {
const themeProvider = injector.get(ThemeProviderService);
const max = props.max ?? 5;
const precision = props.precision ?? 1;
const value = props.value ?? 0;
const filledIcon = props.icon ?? createComponent(Icon, { icon: starIcon, size: "small" });
const emptyIcon = props.emptyIcon ?? createComponent(Icon, { icon: starOutline, size: "small" });
const isInteractive = !props.disabled && !props.readOnly;
const color = themeProvider.theme.palette[props.color || 'warning'].main;
const containerRef = useRef('container');
useHostProps({
'data-size': props.size || 'medium',
style: { '--rating-color': color },
...(isInteractive ? { 'data-spatial-nav-target': '' } : {}),
...(props.disabled ? { 'data-disabled': '', 'aria-disabled': 'true' } : {}),
...(props.readOnly ? { 'data-readonly': '', 'aria-readonly': 'true' } : {}),
...(isInteractive
? {
role: 'slider',
tabIndex: 0,
'aria-valuenow': String(value),
'aria-valuemin': '0',
'aria-valuemax': String(max),
'aria-label': 'Rating',
'aria-orientation': 'horizontal',
onkeydown: (ev) => {
const step = precision === 0.5 ? 0.5 : 1;
switch (ev.key) {
case 'ArrowRight': {
const newValue = Math.min(value + step, max);
if (newValue !== value) {
ev.preventDefault();
props.onValueChange?.(newValue);
}
return;
}
case 'ArrowLeft': {
const newValue = Math.max(value - step, 0);
if (newValue !== value) {
ev.preventDefault();
props.onValueChange?.(newValue);
}
return;
}
case 'Home':
ev.preventDefault();
if (value !== 0) {
props.onValueChange?.(0);
}
return;
case 'End':
ev.preventDefault();
if (value !== max) {
props.onValueChange?.(max);
}
return;
default:
return;
}
},
}
: {
role: 'img',
'aria-label': `Rating: ${value} out of ${max}`,
}),
});
const updateStarVisuals = (displayValue) => {
const container = containerRef.current;
if (!container)
return;
const starEls = container.querySelectorAll('.rating-star');
starEls.forEach((starEl, index) => {
const starValue = index + 1;
const filled = starEl.querySelector('.star-filled');
if (!filled)
return;
if (displayValue >= starValue) {
filled.style.width = '100%';
}
else if (precision === 0.5 && displayValue >= starValue - 0.5) {
filled.style.width = '50%';
}
else {
filled.style.width = '0%';
}
});
};
const getValueFromMouseEvent = (ev, index) => {
const starEl = ev.currentTarget;
const rect = starEl.getBoundingClientRect();
const x = ev.clientX - rect.left;
if (precision === 0.5 && x < rect.width / 2) {
return index + 0.5;
}
return index + 1;
};
const handleClick = (ev, index) => {
if (!isInteractive)
return;
const hostEl = containerRef.current?.closest('shade-rating');
hostEl?.focus();
const newValue = getValueFromMouseEvent(ev, index);
props.onValueChange?.(newValue);
};
const handleMouseMove = (ev, index) => {
if (!isInteractive)
return;
const hoverValue = getValueFromMouseEvent(ev, index);
updateStarVisuals(hoverValue);
};
const handleMouseLeave = () => {
if (!isInteractive)
return;
updateStarVisuals(value);
};
const stars = Array.from({ length: max }, (_, i) => {
const starValue = i + 1;
const isFilled = value >= starValue;
const isHalf = precision === 0.5 && !isFilled && value >= starValue - 0.5;
const initialWidth = isFilled ? '100%' : isHalf ? '50%' : '0%';
return (createComponent("span", { className: "rating-star", "aria-hidden": "true", "data-value": String(starValue), onclick: (ev) => handleClick(ev, i), onmousemove: (ev) => handleMouseMove(ev, i) },
createComponent("span", { className: "star-empty" }, emptyIcon),
createComponent("span", { className: "star-filled", style: { width: initialWidth } }, filledIcon)));
});
return (createComponent("div", { ref: containerRef, className: "rating-container", onmouseleave: handleMouseLeave },
stars,
props.name ? createComponent("input", { type: "hidden", name: props.name, value: String(value) }) : null));
},
});
//# sourceMappingURL=rating.js.map