Files
limitless-ui/src/components/Rating.tsx
Claude 7eb18b15b8 fix(rsc): add "use client" directive to all client-interactive files
50 .tsx components and 6 .ts hook files use React client APIs
(createContext, useState, useEffect, useRef, useMemo, useCallback,
forwardRef, etc.) but lacked the "use client" directive at the top
of the source file. tsc-emitted dist files therefore also lacked it.

Older Next.js / Turbopack versions traversed the import graph and
implicitly treated the imports as client when their consumer was a
client component. Next.js 16's stricter Turbopack rejects this: any
file that touches client-only React APIs must declare "use client"
explicitly, regardless of where it is imported from.

Symptom in consumers (gscSupport build, 2026-05-17):

  ./templates/limitless-ui/dist/theme/ThemeProvider.js:2:10
  You're importing a module that depends on `createContext` into a
  React Server Component module. This API is only available in
  Client Components. To fix, mark the file (or its parent) with
  the `"use client"` directive.

Adding the directive at source-file level cascades through tsc into
the emitted dist/ — verified gscSupport + gscCRM build cleanly after
this change.

Affected files (50 total):
  src/theme/ThemeProvider.tsx
  src/hooks/useDisclosure.ts
  src/components/{Accordion,Carousel,DualListBox,Form,Wizard,…}.tsx
  src/validation/hooks/{useValidation,useFieldValidation,…}.ts
  src/genui/hooks/{useGenUI,useWebMCP}.ts
  ... and more — every component that touches createContext / a
  React hook now self-declares as client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:36:40 +02:00

234 lines
6.5 KiB
TypeScript

"use client";
import React, { useState, useCallback } from 'react';
export interface RatingProps {
/** Current value (controlled) */
value?: number;
/** Default value */
defaultValue?: number;
/** Maximum rating value */
max?: number;
/** Callback when value changes */
onChange?: (value: number) => void;
/** Allow half values */
allowHalf?: boolean;
/** Allow clearing (clicking same value) */
allowClear?: boolean;
/** Disabled state */
disabled?: boolean;
/** Read-only state */
readOnly?: boolean;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Color */
color?: string;
/** Empty color */
emptyColor?: string;
/** Custom icon */
icon?: React.ReactNode;
/** Custom empty icon */
emptyIcon?: React.ReactNode;
/** Custom half icon */
halfIcon?: React.ReactNode;
/** Character to display (alternative to icon) */
character?: React.ReactNode;
/** Show value text */
showValue?: boolean;
/** Custom value formatter */
formatValue?: (value: number) => string;
/** Tooltips for each value */
tooltips?: string[];
/** Additional CSS classes */
className?: string;
}
const StarIcon: React.FC<{ filled?: boolean; half?: boolean }> = ({ filled, half }) => (
<svg viewBox="0 0 24 24" width="1em" height="1em" fill={filled || half ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
{half ? (
<>
<defs>
<linearGradient id="half-star">
<stop offset="50%" stopColor="currentColor" />
<stop offset="50%" stopColor="transparent" />
</linearGradient>
</defs>
<path fill="url(#half-star)" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</>
) : (
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
)}
</svg>
);
export const Rating: React.FC<RatingProps> = ({
value: controlledValue,
defaultValue = 0,
max = 5,
onChange,
allowHalf = false,
allowClear = true,
disabled = false,
readOnly = false,
size = 'md',
color = '#ffc107',
emptyColor = '#e0e0e0',
icon,
emptyIcon,
halfIcon,
character,
showValue = false,
formatValue = (v) => v.toFixed(allowHalf ? 1 : 0),
tooltips,
className = '',
}) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const [hoverValue, setHoverValue] = useState<number | null>(null);
const value = controlledValue ?? internalValue;
const displayValue = hoverValue ?? value;
const handleClick = useCallback((newValue: number) => {
if (disabled || readOnly) return;
// Allow clear if clicking same value
const finalValue = allowClear && newValue === value ? 0 : newValue;
if (controlledValue === undefined) {
setInternalValue(finalValue);
}
onChange?.(finalValue);
}, [disabled, readOnly, allowClear, value, controlledValue, onChange]);
const handleMouseMove = useCallback((e: React.MouseEvent, index: number) => {
if (disabled || readOnly) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const isHalf = allowHalf && x < rect.width / 2;
setHoverValue(isHalf ? index + 0.5 : index + 1);
}, [disabled, readOnly, allowHalf]);
const handleMouseLeave = () => {
setHoverValue(null);
};
const renderStar = (index: number) => {
const starValue = index + 1;
const isFilled = displayValue >= starValue;
const isHalf = allowHalf && displayValue >= index + 0.5 && displayValue < starValue;
const isActive = !disabled && !readOnly;
let starIcon: React.ReactNode;
if (character) {
starIcon = character;
} else if (isHalf && halfIcon) {
starIcon = halfIcon;
} else if (isFilled && icon) {
starIcon = icon;
} else if (!isFilled && emptyIcon) {
starIcon = emptyIcon;
} else {
starIcon = <StarIcon filled={isFilled} half={isHalf} />;
}
const tooltip = tooltips?.[index];
return (
<span
key={index}
className={`ll-rating-star ${isFilled || isHalf ? 'll-rating-star-filled' : 'll-rating-star-empty'}`}
style={{
color: isFilled || isHalf ? color : emptyColor,
cursor: isActive ? 'pointer' : 'default',
}}
onClick={() => handleClick(allowHalf && isHalf ? index + 0.5 : starValue)}
onMouseMove={(e) => handleMouseMove(e, index)}
title={tooltip}
role="radio"
aria-checked={value === starValue}
>
{starIcon}
</span>
);
};
const classes = [
'll-rating',
`ll-rating-${size}`,
disabled && 'll-rating-disabled',
readOnly && 'll-rating-readonly',
className,
].filter(Boolean).join(' ');
return (
<div
className={classes}
onMouseLeave={handleMouseLeave}
role="radiogroup"
aria-label="Rating"
>
<div className="ll-rating-stars">
{Array.from({ length: max }, (_, i) => renderStar(i))}
</div>
{showValue && (
<span className="ll-rating-value">{formatValue(displayValue)}</span>
)}
</div>
);
};
// Heart Rating variant
export const HeartRating: React.FC<Omit<RatingProps, 'icon' | 'emptyIcon'>> = (props) => {
const HeartIcon = ({ filled }: { filled: boolean }) => (
<svg viewBox="0 0 24 24" width="1em" height="1em" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
return (
<Rating
{...props}
color={props.color || '#e91e63'}
icon={<HeartIcon filled />}
emptyIcon={<HeartIcon filled={false} />}
/>
);
};
// Emoji Rating variant
export interface EmojiRatingProps extends Omit<RatingProps, 'icon' | 'emptyIcon' | 'character' | 'max'> {
emojis?: string[];
}
export const EmojiRating: React.FC<EmojiRatingProps> = ({
emojis = ['😞', '😕', '😐', '🙂', '😄'],
...props
}) => {
const max = emojis.length;
return (
<div className="ll-emoji-rating">
<Rating
{...props}
max={max}
allowHalf={false}
icon={null}
emptyIcon={null}
/>
<div className="ll-emoji-rating-faces">
{emojis.map((emoji, index) => (
<span
key={index}
className={`ll-emoji-rating-face ${(props.value ?? props.defaultValue ?? 0) >= index + 1 ? 'active' : ''}`}
>
{emoji}
</span>
))}
</div>
</div>
);
};