UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

209 lines 8.96 kB
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