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>
234 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
};
|